@openfairygui/backend 0.2.0-alpha.37 → 0.2.0-alpha.38

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/README.md CHANGED
@@ -13,7 +13,7 @@ It owns:
13
13
  - coordinated but non-atomic save semantics
14
14
  - browser-safe project sessions
15
15
  - browser-safe async project storage adapter
16
- - adapter-backed file sessions and backend-local advisory locking
16
+ - adapter-backed file sessions and backend-local session locking
17
17
  - capability discovery
18
18
  - transport-neutral bootstrap
19
19
 
@@ -35,13 +35,20 @@ It also does **not** implement MCP or any transport-specific wire protocol.
35
35
  The root `@openfairygui/backend` entrypoint is browser-safe: pure authoring sessions can run in memory,
36
36
  and browser editors can inject an async storage adapter for OPFS, IndexedDB, ZIP-backed virtual filesystems,
37
37
  or File System Access API bridges. Storage adapters must implement `unlink()` so resource rename/move/remove
38
- can clean up stale source files. The default Node filesystem/runtime lives under `@openfairygui/backend/node`.
39
- File-backed `openSession` hydrates primary resource bytes so browser-safe transactions can rename/move
38
+ can clean up stale source files. Existing browser projects use a session-lifetime Web Lock: a live peer tab
39
+ receives `lock_conflict`, while reload or abrupt document termination releases ownership without leaving a
40
+ persistent `.openfairygui.backend.lock` marker. When Web Locks are unavailable, the storage adapter must
41
+ provide `acquireSessionLock()` with the same atomic cross-context and owner-termination semantics. The default
42
+ Node filesystem/runtime lives under `@openfairygui/backend/node` and retains its advisory lock file behavior.
43
+ Adapter-backed `openSession` hydrates primary resource bytes so browser-safe transactions can rename/move
40
44
  assets or add/replace/remove binary resources. `saveSession` writes replacement bytes before it removes
41
45
  stale source files, preserving the prior file when a write fails.
42
46
  It also compares the source project with a UAM round trip through `ProjectWriter`; sessions with
43
47
  unrepresented persisted properties expose `uamFidelity: 'unsupported'`, and write attempts fail with
44
- `uam_fidelity_unsupported`. Transactions, saves, and materialization are serialized per session.
48
+ `uam_fidelity_unsupported`. Existing projects in browser storage must be opened through this path by
49
+ injecting `createBackendStorageFileSystem(storage)` into `BackendRuntime`; `openProjectSession` is only
50
+ for sessions whose supplied UAM project is authoritative. Transactions, saves, and materialization are
51
+ serialized per session.
45
52
 
46
53
  ## Relationship to other packages
47
54
 
@@ -51,7 +58,7 @@ unrepresented persisted properties expose `uamFidelity: 'unsupported'`, and writ
51
58
 
52
59
  ## Example
53
60
 
54
- Browser-safe project session:
61
+ Browser-safe authoritative UAM project session:
55
62
 
56
63
  ```ts
57
64
  import { BackendRuntime } from '@openfairygui/backend';
@@ -67,7 +74,7 @@ const applied = await runtime.applyTransaction({
67
74
  });
68
75
  ```
69
76
 
70
- Browser-safe project session with injected storage:
77
+ Open an existing project from browser async storage with source-fidelity checks:
71
78
 
72
79
  ```ts
73
80
  import { BackendRuntime, createBackendStorageFileSystem } from '@openfairygui/backend';
@@ -81,15 +88,21 @@ const fileSystem = createBackendStorageFileSystem({
81
88
  async readdir(dirPath) { return storage.readdir(dirPath); },
82
89
  async exists(filePath) { return storage.exists(filePath); },
83
90
  async unlink(filePath) { await storage.remove(filePath); },
91
+ async rmdir(dirPath) { await storage.rmdir(dirPath); },
84
92
  });
85
93
 
94
+ const runtime = new BackendRuntime({ fileSystem });
95
+ const opened = await runtime.openSession({ projectPath: 'ExistingProject' });
96
+ if (!opened.ok) throw new Error(opened.error.message);
97
+ ```
98
+
99
+ Materialize an authoritative UAM project into browser async storage:
100
+
101
+ ```ts
86
102
  const runtime = new BackendRuntime();
87
103
  const opened = runtime.openProjectSession({
88
104
  project: uamProject,
89
- storage: {
90
- fileSystem,
91
- fairyPath: 'Project.fairy',
92
- },
105
+ storage: { fileSystem, fairyPath: 'NewProject/Project.fairy' },
93
106
  });
94
107
  if (!opened.ok) throw new Error(opened.error.message);
95
108
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_runtime = require("./runtime-CQQmzsxV.cjs");
2
+ const require_runtime = require("./runtime-BtavZ2ku.cjs");
3
3
  //#region src/storage.ts
4
4
  var StorageFileStat = class {
5
5
  constructor(kind) {
@@ -17,6 +17,42 @@ function createPathError(code, message) {
17
17
  error.code = code;
18
18
  return error;
19
19
  }
20
+ function getWebLockManager() {
21
+ if (typeof navigator === "undefined") return null;
22
+ const lockManager = navigator.locks;
23
+ return lockManager && typeof lockManager.request === "function" ? lockManager : null;
24
+ }
25
+ function acquireWebSessionLock(lockManager, lockName) {
26
+ return new Promise((resolve, reject) => {
27
+ let releasePlatformLock = () => void 0;
28
+ const held = new Promise((release) => {
29
+ releasePlatformLock = release;
30
+ });
31
+ lockManager.request(lockName, {
32
+ mode: "exclusive",
33
+ ifAvailable: true
34
+ }, async (lock) => {
35
+ if (!lock) {
36
+ reject(createPathError("EEXIST", `Browser session lock is already held: ${lockName}`));
37
+ return;
38
+ }
39
+ let released = false;
40
+ resolve({
41
+ writeMetadata() {
42
+ return Promise.resolve();
43
+ },
44
+ release() {
45
+ if (!released) {
46
+ released = true;
47
+ releasePlatformLock();
48
+ }
49
+ return Promise.resolve();
50
+ }
51
+ });
52
+ await held;
53
+ }).catch(reject);
54
+ });
55
+ }
20
56
  function normalizeStoragePath(value) {
21
57
  const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/");
22
58
  const absolute = normalized.startsWith("/");
@@ -73,7 +109,6 @@ async function inferStat(storage, filePath) {
73
109
  function createBackendStorageFileSystem(storage) {
74
110
  if (typeof storage.unlink !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide unlink() for project resource lifecycle writes.");
75
111
  if (typeof storage.rmdir !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide rmdir() for project resource folder lifecycle writes.");
76
- const lockedPaths = /* @__PURE__ */ new Set();
77
112
  const fileSystem = {
78
113
  stat(filePath) {
79
114
  return inferStat(storage, fileSystem.resolve(filePath));
@@ -109,28 +144,15 @@ function createBackendStorageFileSystem(storage) {
109
144
  const resolved = fileSystem.resolve(filePath);
110
145
  return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
111
146
  },
112
- async openExclusive(filePath) {
113
- const resolved = fileSystem.resolve(filePath);
114
- if (storage.openExclusive) return storage.openExclusive(resolved);
115
- if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) throw createPathError("EEXIST", `Storage path already exists: ${resolved}`);
116
- lockedPaths.add(resolved);
117
- let closed = false;
118
- return {
119
- async writeFile(content) {
120
- if (closed) throw createPathError("EBADF", `Storage lock handle is closed: ${resolved}`);
121
- await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
122
- await storage.writeFile(resolved, content);
123
- },
124
- async close() {
125
- closed = true;
126
- lockedPaths.delete(resolved);
127
- }
128
- };
147
+ async acquireSessionLock(lockPath) {
148
+ const resolved = fileSystem.resolve(lockPath);
149
+ if (storage.acquireSessionLock) return storage.acquireSessionLock(resolved);
150
+ const lockManager = getWebLockManager();
151
+ if (!lockManager) throw createPathError("ENOTSUP", "Browser openSession requires Web Locks or BackendAsyncStorageAdapter.acquireSessionLock().");
152
+ return acquireWebSessionLock(lockManager, `@openfairygui/backend:${resolved}`);
129
153
  },
130
154
  unlink(filePath) {
131
- const resolved = fileSystem.resolve(filePath);
132
- lockedPaths.delete(resolved);
133
- return storage.unlink(resolved);
155
+ return storage.unlink(fileSystem.resolve(filePath));
134
156
  },
135
157
  rmdir(dirPath) {
136
158
  return storage.rmdir(fileSystem.resolve(dirPath));
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as BACKEND_COMPATIBILITY_POLICY, A as BackendSessionSnapshot, B as ListJobsInput, C as BackendJobNotFoundError, D as BackendProjectSessionStorage, E as BackendJobStatus, F as GetCacheSnapshotInput, G as OpenProjectSessionInput, H as MaterializeSessionSnapshot, I as GetEventsInput, J as SaveSessionInput, K as RefreshCacheInput, L as GetEventsSnapshot, M as CacheRefreshFailedError, N as CancelJobInput, O as BackendResult, P as EventCursorInvalidError, Q as BACKEND_CAPABILITY_SCHEMA_VERSION, R as GetJobInput, S as BackendJobNotCancellableError, T as BackendJobSnapshot, U as MaterializeValidationFailedError, V as MaterializeSessionInput, W as MaterializeWriteFailedError, X as SessionStaleWriteError, Y as SessionNotFoundError, Z as UamFidelityUnsupportedError, _ as BackendHostAdapter, a as BackendCacheEntry, b as BackendJobListSnapshot, c as BackendCapabilityManifest, d as BackendEvent, et as BACKEND_CONTRACT_VERSION, f as BackendEventKind, g as BackendFileSystem, h as BackendFileStat, i as BackendArtifactBridgeCapability, it as BackendStage, j as BackendSuccess, k as BackendRuntimeOptions, l as BackendCapabilityUnavailableError, m as BackendFileHandle, n as AdvisoryLockConflictError, nt as BackendMessage, o as BackendCacheSnapshot, p as BackendFailure, q as SavePartialFailureError, r as ApplySessionTransactionInput, rt as BackendResponseMeta, s as BackendCapabilities, t as BackendRuntime, tt as BackendDiagnostic, u as BackendError, v as BackendJobErrors, w as BackendJobProgress, x as BackendJobListStatusFilter, y as BackendJobKind, z as InProcessLockConflictError } from "./runtime-Wu6vGZ-j.cjs";
1
+ import { $ as BACKEND_COMPATIBILITY_POLICY, A as BackendSessionSnapshot, B as ListJobsInput, C as BackendJobProgress, D as BackendResult, E as BackendProjectSessionStorage, F as GetCacheSnapshotInput, G as OpenProjectSessionInput, H as MaterializeSessionSnapshot, I as GetEventsInput, J as SaveSessionInput, K as RefreshCacheInput, L as GetEventsSnapshot, M as CacheRefreshFailedError, N as CancelJobInput, O as BackendRuntimeOptions, P as EventCursorInvalidError, Q as BACKEND_CAPABILITY_SCHEMA_VERSION, R as GetJobInput, S as BackendJobNotFoundError, T as BackendJobStatus, U as MaterializeValidationFailedError, V as MaterializeSessionInput, W as MaterializeWriteFailedError, X as SessionStaleWriteError, Y as SessionNotFoundError, Z as UamFidelityUnsupportedError, _ as BackendJobErrors, a as BackendCacheEntry, b as BackendJobListStatusFilter, c as BackendCapabilityManifest, d as BackendEvent, et as BACKEND_CONTRACT_VERSION, f as BackendEventKind, g as BackendHostAdapter, h as BackendFileSystem, i as BackendArtifactBridgeCapability, it as BackendStage, j as BackendSuccess, k as BackendSessionLock, l as BackendCapabilityUnavailableError, m as BackendFileStat, n as AdvisoryLockConflictError, nt as BackendMessage, o as BackendCacheSnapshot, p as BackendFailure, q as SavePartialFailureError, r as ApplySessionTransactionInput, rt as BackendResponseMeta, s as BackendCapabilities, t as BackendRuntime, tt as BackendDiagnostic, u as BackendError, v as BackendJobKind, w as BackendJobSnapshot, x as BackendJobNotCancellableError, y as BackendJobListSnapshot, z as InProcessLockConflictError } from "./runtime-B7rLrkcR.cjs";
2
2
  import { FileSystem } from "@openfairygui/core/project-io";
3
3
 
4
4
  //#region src/storage.d.ts
@@ -20,8 +20,13 @@ interface BackendAsyncStorageAdapter {
20
20
  readdir(dirPath: string): Promise<string[]>;
21
21
  exists?(filePath: string): Promise<boolean>;
22
22
  stat?(filePath: string): Promise<BackendStorageStatLike>;
23
+ /** Stable cross-context path identity; also scopes the default Web Lock name. */
23
24
  resolvePath?(filePath: string): Promise<string>;
24
- openExclusive?(filePath: string): Promise<BackendFileHandle>;
25
+ /**
26
+ * Optional replacement for Web Locks. Acquisition must be atomic across browser contexts, remain held
27
+ * until release(), and recover automatically when the owning document terminates.
28
+ */
29
+ acquireSessionLock?(lockPath: string): Promise<BackendSessionLock>;
25
30
  unlink(filePath: string): Promise<void>;
26
31
  rmdir(dirPath: string): Promise<void>;
27
32
  join?(...paths: string[]): string;
@@ -31,4 +36,4 @@ interface BackendAsyncStorageAdapter {
31
36
  type BackendStorageFileSystem = BackendFileSystem & FileSystem;
32
37
  declare function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapter): BackendStorageFileSystem;
33
38
  //#endregion
34
- export { type AdvisoryLockConflictError, type ApplySessionTransactionInput, BACKEND_CAPABILITY_SCHEMA_VERSION, BACKEND_COMPATIBILITY_POLICY, BACKEND_CONTRACT_VERSION, type BackendArtifactBridgeCapability, type BackendAsyncStorageAdapter, type BackendCacheEntry, type BackendCacheSnapshot, type BackendCapabilities, type BackendCapabilityManifest, type BackendCapabilityUnavailableError, type BackendDiagnostic, type BackendError, type BackendEvent, type BackendEventKind, type BackendFailure, type BackendFileHandle, type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, type BackendJobErrors, type BackendJobKind, type BackendJobListSnapshot, type BackendJobListStatusFilter, type BackendJobNotCancellableError, type BackendJobNotFoundError, type BackendJobProgress, type BackendJobSnapshot, type BackendJobStatus, type BackendMessage, type BackendProjectSessionStorage, type BackendResponseMeta, type BackendResult, BackendRuntime, type BackendRuntimeOptions, type BackendSessionSnapshot, type BackendStage, type BackendStorageFileSystem, type BackendStorageStatLike, type BackendSuccess, type CacheRefreshFailedError, type CancelJobInput, type EventCursorInvalidError, type GetCacheSnapshotInput, type GetEventsInput, type GetEventsSnapshot, type GetJobInput, type InProcessLockConflictError, type ListJobsInput, type MaterializeSessionInput, type MaterializeSessionSnapshot, type MaterializeValidationFailedError, type MaterializeWriteFailedError, type OpenProjectSessionInput, type RefreshCacheInput, type SavePartialFailureError, type SaveSessionInput, type SessionNotFoundError, type SessionStaleWriteError, type UamFidelityUnsupportedError, createBackendStorageFileSystem };
39
+ export { type AdvisoryLockConflictError, type ApplySessionTransactionInput, BACKEND_CAPABILITY_SCHEMA_VERSION, BACKEND_COMPATIBILITY_POLICY, BACKEND_CONTRACT_VERSION, type BackendArtifactBridgeCapability, type BackendAsyncStorageAdapter, type BackendCacheEntry, type BackendCacheSnapshot, type BackendCapabilities, type BackendCapabilityManifest, type BackendCapabilityUnavailableError, type BackendDiagnostic, type BackendError, type BackendEvent, type BackendEventKind, type BackendFailure, type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, type BackendJobErrors, type BackendJobKind, type BackendJobListSnapshot, type BackendJobListStatusFilter, type BackendJobNotCancellableError, type BackendJobNotFoundError, type BackendJobProgress, type BackendJobSnapshot, type BackendJobStatus, type BackendMessage, type BackendProjectSessionStorage, type BackendResponseMeta, type BackendResult, BackendRuntime, type BackendRuntimeOptions, type BackendSessionLock, type BackendSessionSnapshot, type BackendStage, type BackendStorageFileSystem, type BackendStorageStatLike, type BackendSuccess, type CacheRefreshFailedError, type CancelJobInput, type EventCursorInvalidError, type GetCacheSnapshotInput, type GetEventsInput, type GetEventsSnapshot, type GetJobInput, type InProcessLockConflictError, type ListJobsInput, type MaterializeSessionInput, type MaterializeSessionSnapshot, type MaterializeValidationFailedError, type MaterializeWriteFailedError, type OpenProjectSessionInput, type RefreshCacheInput, type SavePartialFailureError, type SaveSessionInput, type SessionNotFoundError, type SessionStaleWriteError, type UamFidelityUnsupportedError, createBackendStorageFileSystem };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as BACKEND_COMPATIBILITY_POLICY, A as BackendSessionSnapshot, B as ListJobsInput, C as BackendJobNotFoundError, D as BackendProjectSessionStorage, E as BackendJobStatus, F as GetCacheSnapshotInput, G as OpenProjectSessionInput, H as MaterializeSessionSnapshot, I as GetEventsInput, J as SaveSessionInput, K as RefreshCacheInput, L as GetEventsSnapshot, M as CacheRefreshFailedError, N as CancelJobInput, O as BackendResult, P as EventCursorInvalidError, Q as BACKEND_CAPABILITY_SCHEMA_VERSION, R as GetJobInput, S as BackendJobNotCancellableError, T as BackendJobSnapshot, U as MaterializeValidationFailedError, V as MaterializeSessionInput, W as MaterializeWriteFailedError, X as SessionStaleWriteError, Y as SessionNotFoundError, Z as UamFidelityUnsupportedError, _ as BackendHostAdapter, a as BackendCacheEntry, b as BackendJobListSnapshot, c as BackendCapabilityManifest, d as BackendEvent, et as BACKEND_CONTRACT_VERSION, f as BackendEventKind, g as BackendFileSystem, h as BackendFileStat, i as BackendArtifactBridgeCapability, it as BackendStage, j as BackendSuccess, k as BackendRuntimeOptions, l as BackendCapabilityUnavailableError, m as BackendFileHandle, n as AdvisoryLockConflictError, nt as BackendMessage, o as BackendCacheSnapshot, p as BackendFailure, q as SavePartialFailureError, r as ApplySessionTransactionInput, rt as BackendResponseMeta, s as BackendCapabilities, t as BackendRuntime, tt as BackendDiagnostic, u as BackendError, v as BackendJobErrors, w as BackendJobProgress, x as BackendJobListStatusFilter, y as BackendJobKind, z as InProcessLockConflictError } from "./runtime-BQOEw22M.mjs";
1
+ import { $ as BACKEND_COMPATIBILITY_POLICY, A as BackendSessionSnapshot, B as ListJobsInput, C as BackendJobProgress, D as BackendResult, E as BackendProjectSessionStorage, F as GetCacheSnapshotInput, G as OpenProjectSessionInput, H as MaterializeSessionSnapshot, I as GetEventsInput, J as SaveSessionInput, K as RefreshCacheInput, L as GetEventsSnapshot, M as CacheRefreshFailedError, N as CancelJobInput, O as BackendRuntimeOptions, P as EventCursorInvalidError, Q as BACKEND_CAPABILITY_SCHEMA_VERSION, R as GetJobInput, S as BackendJobNotFoundError, T as BackendJobStatus, U as MaterializeValidationFailedError, V as MaterializeSessionInput, W as MaterializeWriteFailedError, X as SessionStaleWriteError, Y as SessionNotFoundError, Z as UamFidelityUnsupportedError, _ as BackendJobErrors, a as BackendCacheEntry, b as BackendJobListStatusFilter, c as BackendCapabilityManifest, d as BackendEvent, et as BACKEND_CONTRACT_VERSION, f as BackendEventKind, g as BackendHostAdapter, h as BackendFileSystem, i as BackendArtifactBridgeCapability, it as BackendStage, j as BackendSuccess, k as BackendSessionLock, l as BackendCapabilityUnavailableError, m as BackendFileStat, n as AdvisoryLockConflictError, nt as BackendMessage, o as BackendCacheSnapshot, p as BackendFailure, q as SavePartialFailureError, r as ApplySessionTransactionInput, rt as BackendResponseMeta, s as BackendCapabilities, t as BackendRuntime, tt as BackendDiagnostic, u as BackendError, v as BackendJobKind, w as BackendJobSnapshot, x as BackendJobNotCancellableError, y as BackendJobListSnapshot, z as InProcessLockConflictError } from "./runtime-CJnoedTA.mjs";
2
2
  import { FileSystem } from "@openfairygui/core/project-io";
3
3
 
4
4
  //#region src/storage.d.ts
@@ -20,8 +20,13 @@ interface BackendAsyncStorageAdapter {
20
20
  readdir(dirPath: string): Promise<string[]>;
21
21
  exists?(filePath: string): Promise<boolean>;
22
22
  stat?(filePath: string): Promise<BackendStorageStatLike>;
23
+ /** Stable cross-context path identity; also scopes the default Web Lock name. */
23
24
  resolvePath?(filePath: string): Promise<string>;
24
- openExclusive?(filePath: string): Promise<BackendFileHandle>;
25
+ /**
26
+ * Optional replacement for Web Locks. Acquisition must be atomic across browser contexts, remain held
27
+ * until release(), and recover automatically when the owning document terminates.
28
+ */
29
+ acquireSessionLock?(lockPath: string): Promise<BackendSessionLock>;
25
30
  unlink(filePath: string): Promise<void>;
26
31
  rmdir(dirPath: string): Promise<void>;
27
32
  join?(...paths: string[]): string;
@@ -31,4 +36,4 @@ interface BackendAsyncStorageAdapter {
31
36
  type BackendStorageFileSystem = BackendFileSystem & FileSystem;
32
37
  declare function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapter): BackendStorageFileSystem;
33
38
  //#endregion
34
- export { type AdvisoryLockConflictError, type ApplySessionTransactionInput, BACKEND_CAPABILITY_SCHEMA_VERSION, BACKEND_COMPATIBILITY_POLICY, BACKEND_CONTRACT_VERSION, type BackendArtifactBridgeCapability, type BackendAsyncStorageAdapter, type BackendCacheEntry, type BackendCacheSnapshot, type BackendCapabilities, type BackendCapabilityManifest, type BackendCapabilityUnavailableError, type BackendDiagnostic, type BackendError, type BackendEvent, type BackendEventKind, type BackendFailure, type BackendFileHandle, type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, type BackendJobErrors, type BackendJobKind, type BackendJobListSnapshot, type BackendJobListStatusFilter, type BackendJobNotCancellableError, type BackendJobNotFoundError, type BackendJobProgress, type BackendJobSnapshot, type BackendJobStatus, type BackendMessage, type BackendProjectSessionStorage, type BackendResponseMeta, type BackendResult, BackendRuntime, type BackendRuntimeOptions, type BackendSessionSnapshot, type BackendStage, type BackendStorageFileSystem, type BackendStorageStatLike, type BackendSuccess, type CacheRefreshFailedError, type CancelJobInput, type EventCursorInvalidError, type GetCacheSnapshotInput, type GetEventsInput, type GetEventsSnapshot, type GetJobInput, type InProcessLockConflictError, type ListJobsInput, type MaterializeSessionInput, type MaterializeSessionSnapshot, type MaterializeValidationFailedError, type MaterializeWriteFailedError, type OpenProjectSessionInput, type RefreshCacheInput, type SavePartialFailureError, type SaveSessionInput, type SessionNotFoundError, type SessionStaleWriteError, type UamFidelityUnsupportedError, createBackendStorageFileSystem };
39
+ export { type AdvisoryLockConflictError, type ApplySessionTransactionInput, BACKEND_CAPABILITY_SCHEMA_VERSION, BACKEND_COMPATIBILITY_POLICY, BACKEND_CONTRACT_VERSION, type BackendArtifactBridgeCapability, type BackendAsyncStorageAdapter, type BackendCacheEntry, type BackendCacheSnapshot, type BackendCapabilities, type BackendCapabilityManifest, type BackendCapabilityUnavailableError, type BackendDiagnostic, type BackendError, type BackendEvent, type BackendEventKind, type BackendFailure, type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, type BackendJobErrors, type BackendJobKind, type BackendJobListSnapshot, type BackendJobListStatusFilter, type BackendJobNotCancellableError, type BackendJobNotFoundError, type BackendJobProgress, type BackendJobSnapshot, type BackendJobStatus, type BackendMessage, type BackendProjectSessionStorage, type BackendResponseMeta, type BackendResult, BackendRuntime, type BackendRuntimeOptions, type BackendSessionLock, type BackendSessionSnapshot, type BackendStage, type BackendStorageFileSystem, type BackendStorageStatLike, type BackendSuccess, type CacheRefreshFailedError, type CancelJobInput, type EventCursorInvalidError, type GetCacheSnapshotInput, type GetEventsInput, type GetEventsSnapshot, type GetJobInput, type InProcessLockConflictError, type ListJobsInput, type MaterializeSessionInput, type MaterializeSessionSnapshot, type MaterializeValidationFailedError, type MaterializeWriteFailedError, type OpenProjectSessionInput, type RefreshCacheInput, type SavePartialFailureError, type SaveSessionInput, type SessionNotFoundError, type SessionStaleWriteError, type UamFidelityUnsupportedError, createBackendStorageFileSystem };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as BACKEND_CONTRACT_VERSION, n as BACKEND_CAPABILITY_SCHEMA_VERSION, r as BACKEND_COMPATIBILITY_POLICY, t as BackendRuntime } from "./runtime-DtCn0Y44.mjs";
1
+ import { i as BACKEND_CONTRACT_VERSION, n as BACKEND_CAPABILITY_SCHEMA_VERSION, r as BACKEND_COMPATIBILITY_POLICY, t as BackendRuntime } from "./runtime-CNTJCi6P.mjs";
2
2
  //#region src/storage.ts
3
3
  var StorageFileStat = class {
4
4
  constructor(kind) {
@@ -16,6 +16,42 @@ function createPathError(code, message) {
16
16
  error.code = code;
17
17
  return error;
18
18
  }
19
+ function getWebLockManager() {
20
+ if (typeof navigator === "undefined") return null;
21
+ const lockManager = navigator.locks;
22
+ return lockManager && typeof lockManager.request === "function" ? lockManager : null;
23
+ }
24
+ function acquireWebSessionLock(lockManager, lockName) {
25
+ return new Promise((resolve, reject) => {
26
+ let releasePlatformLock = () => void 0;
27
+ const held = new Promise((release) => {
28
+ releasePlatformLock = release;
29
+ });
30
+ lockManager.request(lockName, {
31
+ mode: "exclusive",
32
+ ifAvailable: true
33
+ }, async (lock) => {
34
+ if (!lock) {
35
+ reject(createPathError("EEXIST", `Browser session lock is already held: ${lockName}`));
36
+ return;
37
+ }
38
+ let released = false;
39
+ resolve({
40
+ writeMetadata() {
41
+ return Promise.resolve();
42
+ },
43
+ release() {
44
+ if (!released) {
45
+ released = true;
46
+ releasePlatformLock();
47
+ }
48
+ return Promise.resolve();
49
+ }
50
+ });
51
+ await held;
52
+ }).catch(reject);
53
+ });
54
+ }
19
55
  function normalizeStoragePath(value) {
20
56
  const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/");
21
57
  const absolute = normalized.startsWith("/");
@@ -72,7 +108,6 @@ async function inferStat(storage, filePath) {
72
108
  function createBackendStorageFileSystem(storage) {
73
109
  if (typeof storage.unlink !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide unlink() for project resource lifecycle writes.");
74
110
  if (typeof storage.rmdir !== "function") throw createPathError("ENOTSUP", "Storage adapter must provide rmdir() for project resource folder lifecycle writes.");
75
- const lockedPaths = /* @__PURE__ */ new Set();
76
111
  const fileSystem = {
77
112
  stat(filePath) {
78
113
  return inferStat(storage, fileSystem.resolve(filePath));
@@ -108,28 +143,15 @@ function createBackendStorageFileSystem(storage) {
108
143
  const resolved = fileSystem.resolve(filePath);
109
144
  return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
110
145
  },
111
- async openExclusive(filePath) {
112
- const resolved = fileSystem.resolve(filePath);
113
- if (storage.openExclusive) return storage.openExclusive(resolved);
114
- if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) throw createPathError("EEXIST", `Storage path already exists: ${resolved}`);
115
- lockedPaths.add(resolved);
116
- let closed = false;
117
- return {
118
- async writeFile(content) {
119
- if (closed) throw createPathError("EBADF", `Storage lock handle is closed: ${resolved}`);
120
- await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
121
- await storage.writeFile(resolved, content);
122
- },
123
- async close() {
124
- closed = true;
125
- lockedPaths.delete(resolved);
126
- }
127
- };
146
+ async acquireSessionLock(lockPath) {
147
+ const resolved = fileSystem.resolve(lockPath);
148
+ if (storage.acquireSessionLock) return storage.acquireSessionLock(resolved);
149
+ const lockManager = getWebLockManager();
150
+ if (!lockManager) throw createPathError("ENOTSUP", "Browser openSession requires Web Locks or BackendAsyncStorageAdapter.acquireSessionLock().");
151
+ return acquireWebSessionLock(lockManager, `@openfairygui/backend:${resolved}`);
128
152
  },
129
153
  unlink(filePath) {
130
- const resolved = fileSystem.resolve(filePath);
131
- lockedPaths.delete(resolved);
132
- return storage.unlink(resolved);
154
+ return storage.unlink(fileSystem.resolve(filePath));
133
155
  },
134
156
  rmdir(dirPath) {
135
157
  return storage.rmdir(fileSystem.resolve(dirPath));
package/dist/node.cjs CHANGED
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_runtime = require("./runtime-CQQmzsxV.cjs");
24
+ const require_runtime = require("./runtime-BtavZ2ku.cjs");
25
25
  let node_fs_promises = require("node:fs/promises");
26
26
  node_fs_promises = __toESM(node_fs_promises);
27
27
  let node_path = require("node:path");
@@ -58,14 +58,27 @@ function createNodeBackendFileSystem() {
58
58
  return node_path.default.resolve(filePath);
59
59
  }
60
60
  },
61
- async openExclusive(filePath) {
61
+ async acquireSessionLock(filePath) {
62
62
  const handle = await node_fs_promises.default.open(filePath, "wx");
63
+ let closed = false;
64
+ let released = false;
65
+ const closeHandle = async () => {
66
+ if (closed) return;
67
+ await handle.close();
68
+ closed = true;
69
+ };
63
70
  return {
64
- writeFile(content) {
65
- return handle.writeFile(content, "utf-8");
71
+ async writeMetadata(content) {
72
+ await handle.writeFile(content, "utf-8");
73
+ await closeHandle();
66
74
  },
67
- close() {
68
- return handle.close();
75
+ async release() {
76
+ if (released) return;
77
+ await closeHandle();
78
+ await node_fs_promises.default.unlink(filePath).catch((error) => {
79
+ if (error.code !== "ENOENT") throw error;
80
+ });
81
+ released = true;
69
82
  }
70
83
  };
71
84
  },
package/dist/node.d.cts CHANGED
@@ -1,8 +1,8 @@
1
- import { _ as BackendHostAdapter, g as BackendFileSystem, h as BackendFileStat, k as BackendRuntimeOptions, m as BackendFileHandle, t as BackendRuntime } from "./runtime-Wu6vGZ-j.cjs";
1
+ import { O as BackendRuntimeOptions, g as BackendHostAdapter, h as BackendFileSystem, k as BackendSessionLock, m as BackendFileStat, t as BackendRuntime } from "./runtime-B7rLrkcR.cjs";
2
2
 
3
3
  //#region src/node.d.ts
4
4
  declare function createNodeBackendFileSystem(): BackendFileSystem;
5
5
  declare function createNodeBackendHostAdapter(): BackendHostAdapter;
6
6
  declare function createNodeBackendRuntime(options?: BackendRuntimeOptions): BackendRuntime;
7
7
  //#endregion
8
- export { type BackendFileHandle, type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, BackendRuntime, type BackendRuntimeOptions, createNodeBackendFileSystem, createNodeBackendHostAdapter, createNodeBackendRuntime };
8
+ export { type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, BackendRuntime, type BackendRuntimeOptions, type BackendSessionLock, createNodeBackendFileSystem, createNodeBackendHostAdapter, createNodeBackendRuntime };
package/dist/node.d.mts CHANGED
@@ -1,8 +1,8 @@
1
- import { _ as BackendHostAdapter, g as BackendFileSystem, h as BackendFileStat, k as BackendRuntimeOptions, m as BackendFileHandle, t as BackendRuntime } from "./runtime-BQOEw22M.mjs";
1
+ import { O as BackendRuntimeOptions, g as BackendHostAdapter, h as BackendFileSystem, k as BackendSessionLock, m as BackendFileStat, t as BackendRuntime } from "./runtime-CJnoedTA.mjs";
2
2
 
3
3
  //#region src/node.d.ts
4
4
  declare function createNodeBackendFileSystem(): BackendFileSystem;
5
5
  declare function createNodeBackendHostAdapter(): BackendHostAdapter;
6
6
  declare function createNodeBackendRuntime(options?: BackendRuntimeOptions): BackendRuntime;
7
7
  //#endregion
8
- export { type BackendFileHandle, type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, BackendRuntime, type BackendRuntimeOptions, createNodeBackendFileSystem, createNodeBackendHostAdapter, createNodeBackendRuntime };
8
+ export { type BackendFileStat, type BackendFileSystem, type BackendHostAdapter, BackendRuntime, type BackendRuntimeOptions, type BackendSessionLock, createNodeBackendFileSystem, createNodeBackendHostAdapter, createNodeBackendRuntime };
package/dist/node.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as BackendRuntime } from "./runtime-DtCn0Y44.mjs";
1
+ import { t as BackendRuntime } from "./runtime-CNTJCi6P.mjs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  //#region src/node.ts
@@ -33,14 +33,27 @@ function createNodeBackendFileSystem() {
33
33
  return path.resolve(filePath);
34
34
  }
35
35
  },
36
- async openExclusive(filePath) {
36
+ async acquireSessionLock(filePath) {
37
37
  const handle = await fs.open(filePath, "wx");
38
+ let closed = false;
39
+ let released = false;
40
+ const closeHandle = async () => {
41
+ if (closed) return;
42
+ await handle.close();
43
+ closed = true;
44
+ };
38
45
  return {
39
- writeFile(content) {
40
- return handle.writeFile(content, "utf-8");
46
+ async writeMetadata(content) {
47
+ await handle.writeFile(content, "utf-8");
48
+ await closeHandle();
41
49
  },
42
- close() {
43
- return handle.close();
50
+ async release() {
51
+ if (released) return;
52
+ await closeHandle();
53
+ await fs.unlink(filePath).catch((error) => {
54
+ if (error.code !== "ENOENT") throw error;
55
+ });
56
+ released = true;
44
57
  }
45
58
  };
46
59
  },
@@ -1,5 +1,5 @@
1
- import { UamProject, UamTransactionOperation } from "@openfairygui/core/uam";
2
1
  import { ApplyUamTransactionAppError } from "@openfairygui/functions/uam";
2
+ import { UamProject, UamTransactionOperation } from "@openfairygui/core/uam";
3
3
 
4
4
  //#region src/contracts.d.ts
5
5
  declare const BACKEND_CONTRACT_VERSION: "1.1.0-p2";
@@ -49,9 +49,12 @@ interface PathPolicyViolationError {
49
49
  }
50
50
  //#endregion
51
51
  //#region src/runtime/contracts.d.ts
52
- interface BackendFileHandle {
53
- writeFile(content: string): Promise<void>;
54
- close(): Promise<void>;
52
+ /** An exclusive lock owned for the lifetime of one backend session. */
53
+ interface BackendSessionLock {
54
+ /** Persist optional host metadata without changing lock ownership. */
55
+ writeMetadata(content: string): Promise<void>;
56
+ /** Release ownership. Browser implementations must also release when their document terminates. */
57
+ release(): Promise<void>;
55
58
  }
56
59
  interface BackendFileStat {
57
60
  isFile(): boolean;
@@ -68,7 +71,7 @@ interface BackendFileSystem {
68
71
  recursive?: boolean;
69
72
  }): Promise<void>;
70
73
  resolvePath(filePath: string): Promise<string>;
71
- openExclusive(filePath: string): Promise<BackendFileHandle>;
74
+ acquireSessionLock(lockPath: string): Promise<BackendSessionLock>;
72
75
  unlink(filePath: string): Promise<void>;
73
76
  rmdir(dirPath: string): Promise<void>;
74
77
  join(...paths: string[]): string;
@@ -424,10 +427,12 @@ interface ApplySessionTransactionInput {
424
427
  operations: UamTransactionOperation[];
425
428
  }
426
429
  interface OpenProjectSessionInput {
430
+ /** Authoritative UAM project. Use BackendRuntime.openSession() when importing an existing project from storage. */
427
431
  project: UamProject;
428
432
  sessionId?: string;
429
433
  canonicalProjectPath?: string;
430
434
  canonicalPathKey?: string;
435
+ /** Optional writeback target for the authoritative UAM project; this is not an import source. */
431
436
  storage?: BackendProjectSessionStorage;
432
437
  }
433
438
  interface BackendProjectSessionStorage {
@@ -501,4 +506,4 @@ declare class BackendRuntime {
501
506
  refreshCache(input: RefreshCacheInput): BackendResult<BackendJobSnapshot, SessionNotFoundError>;
502
507
  }
503
508
  //#endregion
504
- export { BACKEND_COMPATIBILITY_POLICY as $, BackendSessionSnapshot as A, ListJobsInput as B, BackendJobNotFoundError as C, BackendProjectSessionStorage as D, BackendJobStatus as E, GetCacheSnapshotInput as F, OpenProjectSessionInput as G, MaterializeSessionSnapshot as H, GetEventsInput as I, SaveSessionInput as J, RefreshCacheInput as K, GetEventsSnapshot as L, CacheRefreshFailedError as M, CancelJobInput as N, BackendResult as O, EventCursorInvalidError as P, BACKEND_CAPABILITY_SCHEMA_VERSION as Q, GetJobInput as R, BackendJobNotCancellableError as S, BackendJobSnapshot as T, MaterializeValidationFailedError as U, MaterializeSessionInput as V, MaterializeWriteFailedError as W, SessionStaleWriteError as X, SessionNotFoundError as Y, UamFidelityUnsupportedError as Z, BackendHostAdapter as _, BackendCacheEntry as a, BackendJobListSnapshot as b, BackendCapabilityManifest as c, BackendEvent as d, BACKEND_CONTRACT_VERSION as et, BackendEventKind as f, BackendFileSystem as g, BackendFileStat as h, BackendArtifactBridgeCapability as i, BackendStage as it, BackendSuccess as j, BackendRuntimeOptions as k, BackendCapabilityUnavailableError as l, BackendFileHandle as m, AdvisoryLockConflictError as n, BackendMessage as nt, BackendCacheSnapshot as o, BackendFailure as p, SavePartialFailureError as q, ApplySessionTransactionInput as r, BackendResponseMeta as rt, BackendCapabilities as s, BackendRuntime as t, BackendDiagnostic as tt, BackendError as u, BackendJobErrors as v, BackendJobProgress as w, BackendJobListStatusFilter as x, BackendJobKind as y, InProcessLockConflictError as z };
509
+ export { BACKEND_COMPATIBILITY_POLICY as $, BackendSessionSnapshot as A, ListJobsInput as B, BackendJobProgress as C, BackendResult as D, BackendProjectSessionStorage as E, GetCacheSnapshotInput as F, OpenProjectSessionInput as G, MaterializeSessionSnapshot as H, GetEventsInput as I, SaveSessionInput as J, RefreshCacheInput as K, GetEventsSnapshot as L, CacheRefreshFailedError as M, CancelJobInput as N, BackendRuntimeOptions as O, EventCursorInvalidError as P, BACKEND_CAPABILITY_SCHEMA_VERSION as Q, GetJobInput as R, BackendJobNotFoundError as S, BackendJobStatus as T, MaterializeValidationFailedError as U, MaterializeSessionInput as V, MaterializeWriteFailedError as W, SessionStaleWriteError as X, SessionNotFoundError as Y, UamFidelityUnsupportedError as Z, BackendJobErrors as _, BackendCacheEntry as a, BackendJobListStatusFilter as b, BackendCapabilityManifest as c, BackendEvent as d, BACKEND_CONTRACT_VERSION as et, BackendEventKind as f, BackendHostAdapter as g, BackendFileSystem as h, BackendArtifactBridgeCapability as i, BackendStage as it, BackendSuccess as j, BackendSessionLock as k, BackendCapabilityUnavailableError as l, BackendFileStat as m, AdvisoryLockConflictError as n, BackendMessage as nt, BackendCacheSnapshot as o, BackendFailure as p, SavePartialFailureError as q, ApplySessionTransactionInput as r, BackendResponseMeta as rt, BackendCapabilities as s, BackendRuntime as t, BackendDiagnostic as tt, BackendError as u, BackendJobKind as v, BackendJobSnapshot as w, BackendJobNotCancellableError as x, BackendJobListSnapshot as y, InProcessLockConflictError as z };
@@ -1211,10 +1211,10 @@ var RuntimeService = class {
1211
1211
  holderSessionId: existingSessionId,
1212
1212
  lockFilePath
1213
1213
  });
1214
- let advisoryLock = null;
1214
+ let sessionLock = null;
1215
1215
  try {
1216
- advisoryLock = await fileSystem.openExclusive(lockFilePath);
1217
- await advisoryLock.writeFile(JSON.stringify(this.context.host?.lockMetadata?.({
1216
+ sessionLock = await fileSystem.acquireSessionLock(lockFilePath);
1217
+ await sessionLock.writeMetadata(JSON.stringify(this.context.host?.lockMetadata?.({
1218
1218
  canonicalPathKey,
1219
1219
  canonicalProjectPath,
1220
1220
  lockFilePath
@@ -1222,7 +1222,6 @@ var RuntimeService = class {
1222
1222
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1223
1223
  canonicalPathKey
1224
1224
  }));
1225
- await advisoryLock.close();
1226
1225
  const document = await new _openfairygui_core_project_io.ProjectReader(createProjectReaderFileSystem(fileSystem)).read(fairyPath, { hydrateResourceBytes: true });
1227
1226
  const project = (0, _openfairygui_core_uam.liftDocumentToUamProject)(document);
1228
1227
  const sessionId = randomId();
@@ -1232,6 +1231,7 @@ var RuntimeService = class {
1232
1231
  canonicalProjectPath,
1233
1232
  canonicalPathKey,
1234
1233
  lockFilePath,
1234
+ sessionLock,
1235
1235
  fileSystem,
1236
1236
  project,
1237
1237
  uamFidelity: await hasFullUamFidelity(document, project) ? "full" : "unsupported",
@@ -1258,6 +1258,7 @@ var RuntimeService = class {
1258
1258
  revision: session.revision
1259
1259
  });
1260
1260
  } catch (error) {
1261
+ if (sessionLock) await sessionLock.release().catch(() => void 0);
1261
1262
  if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") return failure("runtime", startedAt, {
1262
1263
  code: "lock_conflict",
1263
1264
  kind: "advisory_lock_conflict",
@@ -1265,10 +1266,10 @@ var RuntimeService = class {
1265
1266
  canonicalPathKey,
1266
1267
  lockFilePath
1267
1268
  });
1268
- if (advisoryLock) {
1269
- await advisoryLock.close().catch(() => void 0);
1270
- await fileSystem.unlink(lockFilePath).catch(() => void 0);
1271
- }
1269
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOTSUP") return failure("runtime", startedAt, {
1270
+ ...createCapabilityUnavailableError("fileSystem"),
1271
+ message: error instanceof Error ? error.message : String(error)
1272
+ });
1272
1273
  throw error;
1273
1274
  }
1274
1275
  }
@@ -1293,6 +1294,7 @@ var RuntimeService = class {
1293
1294
  canonicalProjectPath,
1294
1295
  canonicalPathKey,
1295
1296
  lockFilePath: "",
1297
+ sessionLock: null,
1296
1298
  fileSystem: storage?.fileSystem,
1297
1299
  project: (0, _openfairygui_core_uam.normalizeUamProject)(input.project),
1298
1300
  uamFidelity: "full",
@@ -1329,7 +1331,8 @@ var RuntimeService = class {
1329
1331
  canonicalPathKey: session.canonicalPathKey,
1330
1332
  revision: session.revision
1331
1333
  });
1332
- if (this.context.fileSystem && session.lockFilePath) await this.context.fileSystem.unlink(session.lockFilePath).catch(() => void 0);
1334
+ await session.sessionLock?.release().catch(() => void 0);
1335
+ session.sessionLock = null;
1333
1336
  session.lockHeld = false;
1334
1337
  session.closed = true;
1335
1338
  this.context.sessions.delete(session.sessionId);
@@ -1,5 +1,5 @@
1
- import { ApplyUamTransactionAppError } from "@openfairygui/functions/uam";
2
1
  import { UamProject, UamTransactionOperation } from "@openfairygui/core/uam";
2
+ import { ApplyUamTransactionAppError } from "@openfairygui/functions/uam";
3
3
 
4
4
  //#region src/contracts.d.ts
5
5
  declare const BACKEND_CONTRACT_VERSION: "1.1.0-p2";
@@ -49,9 +49,12 @@ interface PathPolicyViolationError {
49
49
  }
50
50
  //#endregion
51
51
  //#region src/runtime/contracts.d.ts
52
- interface BackendFileHandle {
53
- writeFile(content: string): Promise<void>;
54
- close(): Promise<void>;
52
+ /** An exclusive lock owned for the lifetime of one backend session. */
53
+ interface BackendSessionLock {
54
+ /** Persist optional host metadata without changing lock ownership. */
55
+ writeMetadata(content: string): Promise<void>;
56
+ /** Release ownership. Browser implementations must also release when their document terminates. */
57
+ release(): Promise<void>;
55
58
  }
56
59
  interface BackendFileStat {
57
60
  isFile(): boolean;
@@ -68,7 +71,7 @@ interface BackendFileSystem {
68
71
  recursive?: boolean;
69
72
  }): Promise<void>;
70
73
  resolvePath(filePath: string): Promise<string>;
71
- openExclusive(filePath: string): Promise<BackendFileHandle>;
74
+ acquireSessionLock(lockPath: string): Promise<BackendSessionLock>;
72
75
  unlink(filePath: string): Promise<void>;
73
76
  rmdir(dirPath: string): Promise<void>;
74
77
  join(...paths: string[]): string;
@@ -424,10 +427,12 @@ interface ApplySessionTransactionInput {
424
427
  operations: UamTransactionOperation[];
425
428
  }
426
429
  interface OpenProjectSessionInput {
430
+ /** Authoritative UAM project. Use BackendRuntime.openSession() when importing an existing project from storage. */
427
431
  project: UamProject;
428
432
  sessionId?: string;
429
433
  canonicalProjectPath?: string;
430
434
  canonicalPathKey?: string;
435
+ /** Optional writeback target for the authoritative UAM project; this is not an import source. */
431
436
  storage?: BackendProjectSessionStorage;
432
437
  }
433
438
  interface BackendProjectSessionStorage {
@@ -501,4 +506,4 @@ declare class BackendRuntime {
501
506
  refreshCache(input: RefreshCacheInput): BackendResult<BackendJobSnapshot, SessionNotFoundError>;
502
507
  }
503
508
  //#endregion
504
- export { BACKEND_COMPATIBILITY_POLICY as $, BackendSessionSnapshot as A, ListJobsInput as B, BackendJobNotFoundError as C, BackendProjectSessionStorage as D, BackendJobStatus as E, GetCacheSnapshotInput as F, OpenProjectSessionInput as G, MaterializeSessionSnapshot as H, GetEventsInput as I, SaveSessionInput as J, RefreshCacheInput as K, GetEventsSnapshot as L, CacheRefreshFailedError as M, CancelJobInput as N, BackendResult as O, EventCursorInvalidError as P, BACKEND_CAPABILITY_SCHEMA_VERSION as Q, GetJobInput as R, BackendJobNotCancellableError as S, BackendJobSnapshot as T, MaterializeValidationFailedError as U, MaterializeSessionInput as V, MaterializeWriteFailedError as W, SessionStaleWriteError as X, SessionNotFoundError as Y, UamFidelityUnsupportedError as Z, BackendHostAdapter as _, BackendCacheEntry as a, BackendJobListSnapshot as b, BackendCapabilityManifest as c, BackendEvent as d, BACKEND_CONTRACT_VERSION as et, BackendEventKind as f, BackendFileSystem as g, BackendFileStat as h, BackendArtifactBridgeCapability as i, BackendStage as it, BackendSuccess as j, BackendRuntimeOptions as k, BackendCapabilityUnavailableError as l, BackendFileHandle as m, AdvisoryLockConflictError as n, BackendMessage as nt, BackendCacheSnapshot as o, BackendFailure as p, SavePartialFailureError as q, ApplySessionTransactionInput as r, BackendResponseMeta as rt, BackendCapabilities as s, BackendRuntime as t, BackendDiagnostic as tt, BackendError as u, BackendJobErrors as v, BackendJobProgress as w, BackendJobListStatusFilter as x, BackendJobKind as y, InProcessLockConflictError as z };
509
+ export { BACKEND_COMPATIBILITY_POLICY as $, BackendSessionSnapshot as A, ListJobsInput as B, BackendJobProgress as C, BackendResult as D, BackendProjectSessionStorage as E, GetCacheSnapshotInput as F, OpenProjectSessionInput as G, MaterializeSessionSnapshot as H, GetEventsInput as I, SaveSessionInput as J, RefreshCacheInput as K, GetEventsSnapshot as L, CacheRefreshFailedError as M, CancelJobInput as N, BackendRuntimeOptions as O, EventCursorInvalidError as P, BACKEND_CAPABILITY_SCHEMA_VERSION as Q, GetJobInput as R, BackendJobNotFoundError as S, BackendJobStatus as T, MaterializeValidationFailedError as U, MaterializeSessionInput as V, MaterializeWriteFailedError as W, SessionStaleWriteError as X, SessionNotFoundError as Y, UamFidelityUnsupportedError as Z, BackendJobErrors as _, BackendCacheEntry as a, BackendJobListStatusFilter as b, BackendCapabilityManifest as c, BackendEvent as d, BACKEND_CONTRACT_VERSION as et, BackendEventKind as f, BackendHostAdapter as g, BackendFileSystem as h, BackendArtifactBridgeCapability as i, BackendStage as it, BackendSuccess as j, BackendSessionLock as k, BackendCapabilityUnavailableError as l, BackendFileStat as m, AdvisoryLockConflictError as n, BackendMessage as nt, BackendCacheSnapshot as o, BackendFailure as p, SavePartialFailureError as q, ApplySessionTransactionInput as r, BackendResponseMeta as rt, BackendCapabilities as s, BackendRuntime as t, BackendDiagnostic as tt, BackendError as u, BackendJobKind as v, BackendJobSnapshot as w, BackendJobNotCancellableError as x, BackendJobListSnapshot as y, InProcessLockConflictError as z };
@@ -1210,10 +1210,10 @@ var RuntimeService = class {
1210
1210
  holderSessionId: existingSessionId,
1211
1211
  lockFilePath
1212
1212
  });
1213
- let advisoryLock = null;
1213
+ let sessionLock = null;
1214
1214
  try {
1215
- advisoryLock = await fileSystem.openExclusive(lockFilePath);
1216
- await advisoryLock.writeFile(JSON.stringify(this.context.host?.lockMetadata?.({
1215
+ sessionLock = await fileSystem.acquireSessionLock(lockFilePath);
1216
+ await sessionLock.writeMetadata(JSON.stringify(this.context.host?.lockMetadata?.({
1217
1217
  canonicalPathKey,
1218
1218
  canonicalProjectPath,
1219
1219
  lockFilePath
@@ -1221,7 +1221,6 @@ var RuntimeService = class {
1221
1221
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1222
1222
  canonicalPathKey
1223
1223
  }));
1224
- await advisoryLock.close();
1225
1224
  const document = await new ProjectReader(createProjectReaderFileSystem(fileSystem)).read(fairyPath, { hydrateResourceBytes: true });
1226
1225
  const project = liftDocumentToUamProject(document);
1227
1226
  const sessionId = randomId();
@@ -1231,6 +1230,7 @@ var RuntimeService = class {
1231
1230
  canonicalProjectPath,
1232
1231
  canonicalPathKey,
1233
1232
  lockFilePath,
1233
+ sessionLock,
1234
1234
  fileSystem,
1235
1235
  project,
1236
1236
  uamFidelity: await hasFullUamFidelity(document, project) ? "full" : "unsupported",
@@ -1257,6 +1257,7 @@ var RuntimeService = class {
1257
1257
  revision: session.revision
1258
1258
  });
1259
1259
  } catch (error) {
1260
+ if (sessionLock) await sessionLock.release().catch(() => void 0);
1260
1261
  if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") return failure("runtime", startedAt, {
1261
1262
  code: "lock_conflict",
1262
1263
  kind: "advisory_lock_conflict",
@@ -1264,10 +1265,10 @@ var RuntimeService = class {
1264
1265
  canonicalPathKey,
1265
1266
  lockFilePath
1266
1267
  });
1267
- if (advisoryLock) {
1268
- await advisoryLock.close().catch(() => void 0);
1269
- await fileSystem.unlink(lockFilePath).catch(() => void 0);
1270
- }
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
+ });
1271
1272
  throw error;
1272
1273
  }
1273
1274
  }
@@ -1292,6 +1293,7 @@ var RuntimeService = class {
1292
1293
  canonicalProjectPath,
1293
1294
  canonicalPathKey,
1294
1295
  lockFilePath: "",
1296
+ sessionLock: null,
1295
1297
  fileSystem: storage?.fileSystem,
1296
1298
  project: normalizeUamProject(input.project),
1297
1299
  uamFidelity: "full",
@@ -1328,7 +1330,8 @@ var RuntimeService = class {
1328
1330
  canonicalPathKey: session.canonicalPathKey,
1329
1331
  revision: session.revision
1330
1332
  });
1331
- if (this.context.fileSystem && session.lockFilePath) await this.context.fileSystem.unlink(session.lockFilePath).catch(() => void 0);
1333
+ await session.sessionLock?.release().catch(() => void 0);
1334
+ session.sessionLock = null;
1332
1335
  session.lockHeld = false;
1333
1336
  session.closed = true;
1334
1337
  this.context.sessions.delete(session.sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfairygui/backend",
3
- "version": "0.2.0-alpha.37",
3
+ "version": "0.2.0-alpha.38",
4
4
  "description": "FairyGUI Headless Authoring SDK — stateful backend runtime and session services.",
5
5
  "author": "OpenFairyGUI Contributors",
6
6
  "license": "MIT",
@@ -52,8 +52,8 @@
52
52
  "runtime"
53
53
  ],
54
54
  "dependencies": {
55
- "@openfairygui/core": "0.2.0-alpha.37",
56
- "@openfairygui/functions": "0.2.0-alpha.37"
55
+ "@openfairygui/core": "0.2.0-alpha.38",
56
+ "@openfairygui/functions": "0.2.0-alpha.38"
57
57
  },
58
58
  "devDependencies": {
59
59
  "ava": "^7.0.0",
package/src/index.ts CHANGED
@@ -20,7 +20,6 @@ export {
20
20
  type BackendEvent,
21
21
  type BackendEventKind,
22
22
  type BackendFailure,
23
- type BackendFileHandle,
24
23
  type BackendFileStat,
25
24
  type BackendFileSystem,
26
25
  type BackendHostAdapter,
@@ -37,6 +36,7 @@ export {
37
36
  type BackendResult,
38
37
  BackendRuntime,
39
38
  type BackendRuntimeOptions,
39
+ type BackendSessionLock,
40
40
  type BackendSessionSnapshot,
41
41
  type BackendSuccess,
42
42
  type CacheRefreshFailedError,
package/src/node.ts CHANGED
@@ -2,11 +2,11 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  BackendRuntime,
5
- type BackendFileHandle,
6
5
  type BackendFileStat,
7
6
  type BackendFileSystem,
8
7
  type BackendHostAdapter,
9
8
  type BackendRuntimeOptions,
9
+ type BackendSessionLock,
10
10
  } from './runtime.js';
11
11
 
12
12
  export function createNodeBackendFileSystem(): BackendFileSystem {
@@ -40,14 +40,27 @@ export function createNodeBackendFileSystem(): BackendFileSystem {
40
40
  return path.resolve(filePath);
41
41
  }
42
42
  },
43
- async openExclusive(filePath: string): Promise<BackendFileHandle> {
43
+ async acquireSessionLock(filePath: string): Promise<BackendSessionLock> {
44
44
  const handle = await fs.open(filePath, 'wx');
45
+ let closed = false;
46
+ let released = false;
47
+ const closeHandle = async (): Promise<void> => {
48
+ if (closed) return;
49
+ await handle.close();
50
+ closed = true;
51
+ };
45
52
  return {
46
- writeFile(content: string): Promise<void> {
47
- return handle.writeFile(content, 'utf-8');
53
+ async writeMetadata(content: string): Promise<void> {
54
+ await handle.writeFile(content, 'utf-8');
55
+ await closeHandle();
48
56
  },
49
- close(): Promise<void> {
50
- return handle.close();
57
+ async release(): Promise<void> {
58
+ if (released) return;
59
+ await closeHandle();
60
+ await fs.unlink(filePath).catch((error: NodeJS.ErrnoException) => {
61
+ if (error.code !== 'ENOENT') throw error;
62
+ });
63
+ released = true;
51
64
  },
52
65
  };
53
66
  },
@@ -89,11 +102,11 @@ export function createNodeBackendRuntime(options: BackendRuntimeOptions = {}): B
89
102
  });
90
103
  }
91
104
 
92
- export { BackendRuntime };
93
105
  export type {
94
- BackendFileHandle,
95
106
  BackendFileStat,
96
107
  BackendFileSystem,
97
108
  BackendHostAdapter,
98
109
  BackendRuntimeOptions,
110
+ BackendSessionLock,
99
111
  } from './runtime.js';
112
+ export { BackendRuntime };
@@ -9,9 +9,12 @@ import type {
9
9
  } from '../contracts.js';
10
10
  import type { PathPolicyViolationError } from '../path-policy.js';
11
11
 
12
- export interface BackendFileHandle {
13
- writeFile(content: string): Promise<void>;
14
- close(): Promise<void>;
12
+ /** An exclusive lock owned for the lifetime of one backend session. */
13
+ export interface BackendSessionLock {
14
+ /** Persist optional host metadata without changing lock ownership. */
15
+ writeMetadata(content: string): Promise<void>;
16
+ /** Release ownership. Browser implementations must also release when their document terminates. */
17
+ release(): Promise<void>;
15
18
  }
16
19
 
17
20
  export interface BackendFileStat {
@@ -28,7 +31,7 @@ export interface BackendFileSystem {
28
31
  writeFileRaw(filePath: string, data: Uint8Array): Promise<void>;
29
32
  mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void>;
30
33
  resolvePath(filePath: string): Promise<string>;
31
- openExclusive(filePath: string): Promise<BackendFileHandle>;
34
+ acquireSessionLock(lockPath: string): Promise<BackendSessionLock>;
32
35
  unlink(filePath: string): Promise<void>;
33
36
  rmdir(dirPath: string): Promise<void>;
34
37
  join(...paths: string[]): string;
@@ -474,10 +477,12 @@ export interface ApplySessionTransactionInput {
474
477
  }
475
478
 
476
479
  export interface OpenProjectSessionInput {
480
+ /** Authoritative UAM project. Use BackendRuntime.openSession() when importing an existing project from storage. */
477
481
  project: UamProject;
478
482
  sessionId?: string;
479
483
  canonicalProjectPath?: string;
480
484
  canonicalPathKey?: string;
485
+ /** Optional writeback target for the authoritative UAM project; this is not an import source. */
481
486
  storage?: BackendProjectSessionStorage;
482
487
  }
483
488
 
@@ -15,6 +15,7 @@ import type {
15
15
  BackendFileSystem,
16
16
  BackendHostAdapter,
17
17
  BackendJobSnapshot,
18
+ BackendSessionLock,
18
19
  BackendSessionSnapshot,
19
20
  BackendSuccess,
20
21
  } from '../runtime.js';
@@ -25,6 +26,7 @@ export interface BackendSessionState {
25
26
  canonicalProjectPath: string;
26
27
  canonicalPathKey: string;
27
28
  lockFilePath: string;
29
+ sessionLock: BackendSessionLock | null;
28
30
  fileSystem?: BackendFileSystem;
29
31
  project: import('@openfairygui/core/uam').UamProject;
30
32
  uamFidelity: 'full' | 'unsupported';
@@ -9,8 +9,8 @@ import { normalizeComparablePath, resolveCanonicalProjectRoot } from '../path-po
9
9
  import type {
10
10
  AdvisoryLockConflictError,
11
11
  BackendCapabilityUnavailableError,
12
- BackendFileHandle,
13
12
  BackendResult,
13
+ BackendSessionLock,
14
14
  BackendSessionSnapshot,
15
15
  InProcessLockConflictError,
16
16
  OpenProjectSessionInput,
@@ -204,10 +204,10 @@ export class RuntimeService {
204
204
  });
205
205
  }
206
206
 
207
- let advisoryLock: BackendFileHandle | null = null;
207
+ let sessionLock: BackendSessionLock | null = null;
208
208
  try {
209
- advisoryLock = await fileSystem.openExclusive(lockFilePath);
210
- await advisoryLock.writeFile(
209
+ sessionLock = await fileSystem.acquireSessionLock(lockFilePath);
210
+ await sessionLock.writeMetadata(
211
211
  JSON.stringify(
212
212
  this.context.host?.lockMetadata?.({
213
213
  canonicalPathKey,
@@ -219,8 +219,6 @@ export class RuntimeService {
219
219
  },
220
220
  ),
221
221
  );
222
- await advisoryLock.close();
223
-
224
222
  const reader = new ProjectReader(createProjectReaderFileSystem(fileSystem));
225
223
  const document = await reader.read(fairyPath, { hydrateResourceBytes: true });
226
224
  const project = liftDocumentToUamProject(document);
@@ -231,6 +229,7 @@ export class RuntimeService {
231
229
  canonicalProjectPath,
232
230
  canonicalPathKey,
233
231
  lockFilePath,
232
+ sessionLock,
234
233
  fileSystem,
235
234
  project,
236
235
  uamFidelity: (await hasFullUamFidelity(document, project)) ? 'full' : 'unsupported',
@@ -253,6 +252,7 @@ export class RuntimeService {
253
252
  revision: session.revision,
254
253
  });
255
254
  } catch (error) {
255
+ if (sessionLock) await sessionLock.release().catch(() => undefined);
256
256
  if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
257
257
  return failure('runtime', startedAt, {
258
258
  code: 'lock_conflict',
@@ -262,9 +262,11 @@ export class RuntimeService {
262
262
  lockFilePath,
263
263
  });
264
264
  }
265
- if (advisoryLock) {
266
- await advisoryLock.close().catch(() => undefined);
267
- await fileSystem.unlink(lockFilePath).catch(() => undefined);
265
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOTSUP') {
266
+ return failure('runtime', startedAt, {
267
+ ...createCapabilityUnavailableError('fileSystem'),
268
+ message: error instanceof Error ? error.message : String(error),
269
+ });
268
270
  }
269
271
  throw error;
270
272
  }
@@ -300,6 +302,7 @@ export class RuntimeService {
300
302
  canonicalProjectPath,
301
303
  canonicalPathKey,
302
304
  lockFilePath: '',
305
+ sessionLock: null,
303
306
  fileSystem: storage?.fileSystem,
304
307
  project: normalizeUamProject(input.project),
305
308
  uamFidelity: 'full',
@@ -338,9 +341,8 @@ export class RuntimeService {
338
341
  canonicalPathKey: session.canonicalPathKey,
339
342
  revision: session.revision,
340
343
  });
341
- if (this.context.fileSystem && session.lockFilePath) {
342
- await this.context.fileSystem.unlink(session.lockFilePath).catch(() => undefined);
343
- }
344
+ await session.sessionLock?.release().catch(() => undefined);
345
+ session.sessionLock = null;
344
346
  session.lockHeld = false;
345
347
  session.closed = true;
346
348
  this.context.sessions.delete(session.sessionId);
package/src/storage.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FileSystem as CoreProjectFileSystem } from '@openfairygui/core/project-io';
2
- import type { BackendFileHandle, BackendFileStat, BackendFileSystem } from './runtime.js';
2
+ import type { BackendFileStat, BackendFileSystem, BackendSessionLock } from './runtime.js';
3
3
 
4
4
  type StorageStatKind = 'file' | 'directory';
5
5
 
@@ -19,8 +19,13 @@ export interface BackendAsyncStorageAdapter {
19
19
  readdir(dirPath: string): Promise<string[]>;
20
20
  exists?(filePath: string): Promise<boolean>;
21
21
  stat?(filePath: string): Promise<BackendStorageStatLike>;
22
+ /** Stable cross-context path identity; also scopes the default Web Lock name. */
22
23
  resolvePath?(filePath: string): Promise<string>;
23
- openExclusive?(filePath: string): Promise<BackendFileHandle>;
24
+ /**
25
+ * Optional replacement for Web Locks. Acquisition must be atomic across browser contexts, remain held
26
+ * until release(), and recover automatically when the owning document terminates.
27
+ */
28
+ acquireSessionLock?(lockPath: string): Promise<BackendSessionLock>;
24
29
  unlink(filePath: string): Promise<void>;
25
30
  rmdir(dirPath: string): Promise<void>;
26
31
  join?(...paths: string[]): string;
@@ -46,6 +51,44 @@ function createPathError(code: string, message: string): Error & { code: string
46
51
  return error;
47
52
  }
48
53
 
54
+ function getWebLockManager(): LockManager | null {
55
+ if (typeof navigator === 'undefined') return null;
56
+ const lockManager = (navigator as Navigator & { locks?: LockManager }).locks;
57
+ return lockManager && typeof lockManager.request === 'function' ? lockManager : null;
58
+ }
59
+
60
+ function acquireWebSessionLock(lockManager: LockManager, lockName: string): Promise<BackendSessionLock> {
61
+ return new Promise((resolve, reject) => {
62
+ let releasePlatformLock = (): void => undefined;
63
+ const held = new Promise<void>((release) => {
64
+ releasePlatformLock = release;
65
+ });
66
+ void lockManager
67
+ .request(lockName, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
68
+ if (!lock) {
69
+ reject(createPathError('EEXIST', `Browser session lock is already held: ${lockName}`));
70
+ return;
71
+ }
72
+ let released = false;
73
+ resolve({
74
+ // Web Locks are the authority; a persisted marker would survive abrupt document termination.
75
+ writeMetadata(): Promise<void> {
76
+ return Promise.resolve();
77
+ },
78
+ release(): Promise<void> {
79
+ if (!released) {
80
+ released = true;
81
+ releasePlatformLock();
82
+ }
83
+ return Promise.resolve();
84
+ },
85
+ });
86
+ await held;
87
+ })
88
+ .catch(reject);
89
+ });
90
+ }
91
+
49
92
  function normalizeStoragePath(value: string): string {
50
93
  const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/');
51
94
  const absolute = normalized.startsWith('/');
@@ -121,8 +164,6 @@ export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapt
121
164
  if (typeof storage.rmdir !== 'function') {
122
165
  throw createPathError('ENOTSUP', 'Storage adapter must provide rmdir() for project resource folder lifecycle writes.');
123
166
  }
124
- const lockedPaths = new Set<string>();
125
-
126
167
  const fileSystem: BackendStorageFileSystem = {
127
168
  stat(filePath: string): Promise<BackendFileStat> {
128
169
  return inferStat(storage, fileSystem.resolve(filePath));
@@ -158,30 +199,20 @@ export function createBackendStorageFileSystem(storage: BackendAsyncStorageAdapt
158
199
  const resolved = fileSystem.resolve(filePath);
159
200
  return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
160
201
  },
161
- async openExclusive(filePath: string): Promise<BackendFileHandle> {
162
- const resolved = fileSystem.resolve(filePath);
163
- if (storage.openExclusive) return storage.openExclusive(resolved);
164
- if (lockedPaths.has(resolved) || await fileSystem.exists(resolved)) {
165
- throw createPathError('EEXIST', `Storage path already exists: ${resolved}`);
202
+ async acquireSessionLock(lockPath: string): Promise<BackendSessionLock> {
203
+ const resolved = fileSystem.resolve(lockPath);
204
+ if (storage.acquireSessionLock) return storage.acquireSessionLock(resolved);
205
+ const lockManager = getWebLockManager();
206
+ if (!lockManager) {
207
+ throw createPathError(
208
+ 'ENOTSUP',
209
+ 'Browser openSession requires Web Locks or BackendAsyncStorageAdapter.acquireSessionLock().',
210
+ );
166
211
  }
167
- lockedPaths.add(resolved);
168
- let closed = false;
169
- return {
170
- async writeFile(content: string): Promise<void> {
171
- if (closed) throw createPathError('EBADF', `Storage lock handle is closed: ${resolved}`);
172
- await fileSystem.mkdir(fileSystem.dirname(resolved), { recursive: true });
173
- await storage.writeFile(resolved, content);
174
- },
175
- async close(): Promise<void> {
176
- closed = true;
177
- lockedPaths.delete(resolved);
178
- },
179
- };
212
+ return acquireWebSessionLock(lockManager, `@openfairygui/backend:${resolved}`);
180
213
  },
181
214
  unlink(filePath: string): Promise<void> {
182
- const resolved = fileSystem.resolve(filePath);
183
- lockedPaths.delete(resolved);
184
- return storage.unlink(resolved);
215
+ return storage.unlink(fileSystem.resolve(filePath));
185
216
  },
186
217
  rmdir(dirPath: string): Promise<void> {
187
218
  return storage.rmdir(fileSystem.resolve(dirPath));