@openfairygui/backend 0.2.0-alpha.0 → 0.2.0-alpha.10

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,1091 +1,146 @@
1
- import { NodeIO, ProjectWriter, UAM_SUPPORTED_MATERIALIZATION_SCOPE, materializeUamProject, readProjectAsUam } from "@openfairygui/core";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
- import { applyUamTransactionApp } from "@openfairygui/functions";
5
- //#region src/contracts.ts
6
- const BACKEND_CONTRACT_VERSION = "1.1.0-p2";
7
- const BACKEND_CAPABILITY_SCHEMA_VERSION = 2;
8
- const BACKEND_COMPATIBILITY_POLICY = {
9
- incompatibleChange: "requires contractVersion bump",
10
- capabilitySchemaChange: "requires capabilitySchemaVersion bump",
11
- additiveChange: "allowed without breaking existing consumers"
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";
2
+ //#region src/storage.ts
3
+ var StorageFileStat = class {
4
+ constructor(kind) {
5
+ this.kind = kind;
6
+ }
7
+ isFile() {
8
+ return this.kind === "file";
9
+ }
10
+ isDirectory() {
11
+ return this.kind === "directory";
12
+ }
12
13
  };
13
- //#endregion
14
- //#region src/path-policy.ts
15
- function normalizeComparablePath(value) {
16
- const normalized = value.replace(/[/\\]+$/, "").replace(/\\/g, "/");
17
- const driveMatch = normalized.match(/^([a-z]:)(?:\/(.*))?$/i);
18
- const drivePrefix = driveMatch?.[1].toLowerCase() ?? "";
19
- const remainder = driveMatch ? driveMatch[2] ?? "" : normalized;
20
- const hasRoot = driveMatch ? true : remainder.startsWith("/");
21
- const rawSegments = remainder.split("/").filter((segment) => segment.length > 0);
14
+ function createPathError(code, message) {
15
+ const error = new Error(message);
16
+ error.code = code;
17
+ return error;
18
+ }
19
+ function normalizeStoragePath(value) {
20
+ const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/");
21
+ const absolute = normalized.startsWith("/");
22
+ const rawSegments = normalized.split("/").filter((segment) => segment.length > 0);
22
23
  const segments = [];
23
24
  for (const segment of rawSegments) {
24
25
  if (segment === ".") continue;
25
26
  if (segment === "..") {
26
- if (segments.length > 0 && segments[segments.length - 1] !== "..") segments.pop();
27
- else if (!hasRoot) segments.push("..");
27
+ if (segments.length > 0) segments.pop();
28
28
  continue;
29
29
  }
30
30
  segments.push(segment);
31
31
  }
32
32
  const joined = segments.join("/");
33
- return (drivePrefix ? `${drivePrefix}/${joined}`.replace(/\/$/, "") : hasRoot ? `/${joined}`.replace(/\/$/, "") : joined || ".").toLowerCase();
34
- }
35
- function createRuntimePathPolicy() {
36
- return {
37
- canonicalization: "realpath+normalized-casefold",
38
- sessionIdentity: "project-root",
39
- saveTarget: "opened-project-only",
40
- outputTargets: "deferred",
41
- workspaceBoundary: "project-root-only"
42
- };
43
- }
44
- async function resolveFairyPath(fileSystem, input) {
45
- const resolvedInput = fileSystem.resolve(input);
46
- const stat = await fileSystem.stat(resolvedInput);
47
- if (stat.isFile() && resolvedInput.endsWith(".fairy")) return await fileSystem.resolvePath(resolvedInput);
48
- if (stat.isDirectory()) {
49
- const fairyFiles = (await fileSystem.readdir(resolvedInput)).filter((entry) => entry.endsWith(".fairy"));
50
- if (fairyFiles.length === 1) return await fileSystem.resolvePath(fileSystem.join(resolvedInput, fairyFiles[0]));
51
- if (fairyFiles.length > 1) throw new Error(`Multiple .fairy files found in ${resolvedInput}: ${fairyFiles.join(", ")}`);
52
- throw new Error(`No .fairy file found in ${resolvedInput}`);
53
- }
54
- throw new Error(`Input is not a .fairy file or directory: ${resolvedInput}`);
55
- }
56
- async function resolveCanonicalProjectRoot(fileSystem, input) {
57
- const fairyPath = await resolveFairyPath(fileSystem, input);
58
- const canonicalProjectPath = await fileSystem.resolvePath(fileSystem.dirname(fairyPath));
59
- return {
60
- fairyPath,
61
- canonicalProjectPath,
62
- canonicalPathKey: normalizeComparablePath(canonicalProjectPath)
63
- };
64
- }
65
- async function validateSaveTarget(fileSystem, openedFairyPath, targetPath) {
66
- if (!targetPath) return null;
67
- const attemptedPath = await fileSystem.resolvePath(fileSystem.resolve(targetPath));
68
- const allowedPath = await fileSystem.resolvePath(openedFairyPath);
69
- if (normalizeComparablePath(attemptedPath) === normalizeComparablePath(allowedPath)) return null;
70
- return {
71
- code: "path_policy_violation",
72
- message: `Save target is restricted to the originally opened project file: ${allowedPath}`,
73
- policy: "save_target",
74
- attemptedPath,
75
- allowedPath
76
- };
77
- }
78
- //#endregion
79
- //#region src/services/artifact-service.ts
80
- function createArtifactCapabilities() {
81
- return {
82
- publish: false,
83
- restore: false,
84
- status: "deferred"
85
- };
86
- }
87
- //#endregion
88
- //#region src/services/context.ts
89
- function randomId$1() {
90
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
91
- }
92
- function createMeta(stage, startedAt, options) {
93
- return {
94
- requestId: options?.requestId ?? randomId$1(),
95
- sessionId: options?.sessionId,
96
- revision: options?.revision,
97
- durationMs: Math.max(0, Date.now() - startedAt),
98
- warnings: options?.warnings ?? [],
99
- diagnostics: options?.diagnostics ?? [],
100
- stage,
101
- contractVersion: BACKEND_CONTRACT_VERSION,
102
- capabilitySchemaVersion: 2
103
- };
104
- }
105
- function success(stage, startedAt, data, options) {
106
- return {
107
- ok: true,
108
- meta: createMeta(stage, startedAt, options),
109
- data
110
- };
111
- }
112
- function failure(stage, startedAt, error, session, options) {
113
- return {
114
- ok: false,
115
- meta: createMeta(stage, startedAt, options),
116
- error,
117
- session
118
- };
119
- }
120
- //#endregion
121
- //#region src/services/snapshot-utils.ts
122
- function cloneJsonValue(value) {
123
- if (value === void 0 || value === null) return value;
124
- return JSON.parse(JSON.stringify(value));
125
- }
126
- function cloneEventSnapshot(event) {
127
- return {
128
- ...event,
129
- diagnostics: event.diagnostics.map((diagnostic) => ({ ...diagnostic })),
130
- payload: cloneJsonValue(event.payload)
131
- };
132
- }
133
- function cloneJobSnapshot(job) {
134
- return {
135
- ...job,
136
- diagnostics: job.diagnostics.map((diagnostic) => ({ ...diagnostic })),
137
- progress: job.progress ? { ...job.progress } : void 0,
138
- result: cloneJsonValue(job.result),
139
- error: cloneJsonValue(job.error)
140
- };
141
- }
142
- function cloneCacheEntrySnapshot(entry) {
143
- return {
144
- ...entry,
145
- summary: {
146
- ...entry.summary,
147
- diagnostics: entry.summary.diagnostics.map((diagnostic) => ({ ...diagnostic }))
148
- }
149
- };
150
- }
151
- function cloneCapabilitiesSnapshot(capabilities) {
152
- return cloneJsonValue(capabilities);
153
- }
154
- //#endregion
155
- //#region src/services/session-utils.ts
156
- function toSessionSnapshot(session, capabilities) {
157
- return {
158
- sessionId: session.sessionId,
159
- canonicalProjectPath: session.canonicalProjectPath,
160
- revision: session.revision,
161
- lastSavedRevision: session.lastSavedRevision,
162
- dirty: session.dirty,
163
- lockHeld: session.lockHeld,
164
- capabilities: cloneCapabilitiesSnapshot(capabilities)
165
- };
166
- }
167
- function createSessionNotFoundError(sessionId) {
168
- return {
169
- code: "session_not_found",
170
- message: `Session was not found: ${sessionId}`,
171
- sessionId
172
- };
173
- }
174
- function createStaleWriteError(session, expectedRevision) {
175
- return {
176
- code: "stale_write",
177
- message: `Expected revision ${expectedRevision} does not match current revision ${session.revision}.`,
178
- sessionId: session.sessionId,
179
- canonicalPathKey: session.canonicalPathKey,
180
- expectedRevision,
181
- actualRevision: session.revision
182
- };
183
- }
184
- //#endregion
185
- //#region src/services/authoring-service.ts
186
- function createWriterFileSystem(fileSystem, committedPaths, failedPaths) {
187
- async function trackWrite(targetPath, fn) {
188
- try {
189
- const result = await fn();
190
- committedPaths.push(targetPath);
191
- return result;
192
- } catch (error) {
193
- failedPaths.push(targetPath);
194
- throw error;
195
- }
196
- }
197
- return {
198
- async readFile(filePath) {
199
- return fileSystem.readFile(filePath);
200
- },
201
- async readFileRaw(filePath) {
202
- return fileSystem.readFileRaw(filePath);
203
- },
204
- async writeFile(filePath, content) {
205
- await trackWrite(filePath, async () => {
206
- await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
207
- await fileSystem.writeFile(filePath, content);
208
- });
209
- },
210
- async writeFileRaw(filePath, data) {
211
- await trackWrite(filePath, async () => {
212
- await fileSystem.mkdir(fileSystem.dirname(filePath), { recursive: true });
213
- await fileSystem.writeFileRaw(filePath, data);
214
- });
215
- },
216
- async mkdir(dirPath) {
217
- await fileSystem.mkdir(dirPath, { recursive: true });
218
- },
219
- async readdir(dirPath) {
220
- return fileSystem.readdir(dirPath);
221
- },
222
- async exists(filePath) {
223
- try {
224
- await fileSystem.stat(filePath);
225
- return true;
226
- } catch {
227
- return false;
228
- }
229
- },
230
- join(...paths) {
231
- return fileSystem.join(...paths);
232
- },
233
- dirname(filePath) {
234
- return fileSystem.dirname(filePath);
235
- }
236
- };
237
- }
238
- var AuthoringService = class {
239
- constructor(context, cacheService, eventService) {
240
- this.context = context;
241
- this.cacheService = cacheService;
242
- this.eventService = eventService;
243
- }
244
- async applyTransaction(input) {
245
- const startedAt = Date.now();
246
- const session = this.context.sessions.get(input.sessionId);
247
- if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
248
- if (input.expectedRevision !== session.revision) {
249
- this.eventService.emit({
250
- kind: "transaction.rejected",
251
- sessionId: session.sessionId,
252
- canonicalPathKey: session.canonicalPathKey,
253
- revision: session.revision
254
- });
255
- return failure("authoring", startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
256
- sessionId: session.sessionId,
257
- revision: session.revision
258
- });
259
- }
260
- const result = applyUamTransactionApp({
261
- project: session.project,
262
- operations: input.operations
263
- });
264
- if (result.ok === false) {
265
- this.eventService.emit({
266
- kind: "transaction.rejected",
267
- sessionId: session.sessionId,
268
- canonicalPathKey: session.canonicalPathKey,
269
- revision: session.revision,
270
- diagnostics: result.error.issues?.map((issue) => ({
271
- code: result.error.code,
272
- message: issue.message,
273
- severity: "error"
274
- })) ?? []
275
- });
276
- return failure("authoring", startedAt, result.error, toSessionSnapshot(session, this.context.capabilities), {
277
- sessionId: session.sessionId,
278
- revision: session.revision
279
- });
280
- }
281
- session.project = result.project;
282
- session.revision += 1;
283
- session.dirty = true;
284
- const cacheEntry = this.cacheService.invalidateSession(session);
285
- this.eventService.emit({
286
- kind: "transaction.applied",
287
- sessionId: session.sessionId,
288
- canonicalPathKey: session.canonicalPathKey,
289
- revision: session.revision
290
- });
291
- this.eventService.emit({
292
- kind: "cache.invalidated",
293
- sessionId: session.sessionId,
294
- canonicalPathKey: session.canonicalPathKey,
295
- revision: session.revision,
296
- cacheRevision: cacheEntry.revision
297
- });
298
- return success("authoring", startedAt, toSessionSnapshot(session, this.context.capabilities), {
299
- sessionId: session.sessionId,
300
- revision: session.revision
301
- });
302
- }
303
- async saveSession(input) {
304
- const startedAt = Date.now();
305
- const session = this.context.sessions.get(input.sessionId);
306
- if (!session || session.closed) return failure("authoring", startedAt, createSessionNotFoundError(input.sessionId));
307
- if (input.expectedRevision !== void 0 && input.expectedRevision !== session.revision) return failure("authoring", startedAt, createStaleWriteError(session, input.expectedRevision), toSessionSnapshot(session, this.context.capabilities), {
308
- sessionId: session.sessionId,
309
- revision: session.revision
310
- });
311
- const targetViolation = await validateSaveTarget(this.context.fileSystem, session.fairyPath, input.targetPath);
312
- if (targetViolation) return failure("authoring", startedAt, targetViolation, toSessionSnapshot(session, this.context.capabilities), {
313
- sessionId: session.sessionId,
314
- revision: session.revision
315
- });
316
- if (!session.dirty) return success("authoring", startedAt, toSessionSnapshot(session, this.context.capabilities), {
317
- sessionId: session.sessionId,
318
- revision: session.revision
319
- });
320
- const committedPaths = [];
321
- const failedPaths = [];
322
- this.eventService.emit({
323
- kind: "save.started",
324
- sessionId: session.sessionId,
325
- canonicalPathKey: session.canonicalPathKey,
326
- revision: session.revision
327
- });
328
- try {
329
- await new ProjectWriter(createWriterFileSystem(this.context.fileSystem, committedPaths, failedPaths)).write(materializeUamProject(session.project), session.fairyPath);
330
- session.lastSavedRevision = session.revision;
331
- session.dirty = false;
332
- const cacheEntry = this.cacheService.refreshSession(session);
333
- this.eventService.emit({
334
- kind: "save.completed",
335
- sessionId: session.sessionId,
336
- canonicalPathKey: session.canonicalPathKey,
337
- revision: session.revision
338
- });
339
- this.eventService.emit({
340
- kind: "cache.updated",
341
- sessionId: session.sessionId,
342
- canonicalPathKey: session.canonicalPathKey,
343
- revision: session.revision,
344
- cacheRevision: cacheEntry.revision
345
- });
346
- return success("authoring", startedAt, toSessionSnapshot(session, this.context.capabilities), {
347
- sessionId: session.sessionId,
348
- revision: session.revision
349
- });
350
- } catch (error) {
351
- this.cacheService.invalidateSession(session);
352
- this.eventService.emit({
353
- kind: "save.failed",
354
- sessionId: session.sessionId,
355
- canonicalPathKey: session.canonicalPathKey,
356
- revision: session.revision
357
- });
358
- return failure("authoring", startedAt, {
359
- code: "save_partial_failure",
360
- message: error instanceof Error ? error.message : String(error),
361
- sessionId: session.sessionId,
362
- canonicalPathKey: session.canonicalPathKey,
363
- attemptedRevision: session.revision,
364
- lastSavedRevision: session.lastSavedRevision,
365
- committedPaths,
366
- failedPaths,
367
- diskMayBePartiallyUpdated: true
368
- }, toSessionSnapshot(session, this.context.capabilities), {
369
- sessionId: session.sessionId,
370
- revision: session.revision
371
- });
372
- }
373
- }
374
- };
375
- //#endregion
376
- //#region src/services/cache-service.ts
377
- function createCacheEntry(session, valid) {
378
- return {
379
- canonicalPathKey: session.canonicalPathKey,
380
- sessionId: session.sessionId,
381
- revision: session.revision,
382
- lastSavedRevision: session.lastSavedRevision,
383
- dirty: session.dirty,
384
- valid,
385
- indexedAt: (/* @__PURE__ */ new Date()).toISOString(),
386
- summary: {
387
- packageCount: session.project.packages.length,
388
- resourceCount: session.project.packages.reduce((total, pkg) => total + pkg.resources.length, 0),
389
- diagnostics: []
390
- }
391
- };
392
- }
393
- var CacheService = class {
394
- constructor(context) {
395
- this.context = context;
396
- }
397
- getCacheSnapshot(input) {
398
- const startedAt = Date.now();
399
- const session = this.context.sessions.get(input.sessionId);
400
- if (!session || session.closed) return failure("read", startedAt, createSessionNotFoundError(input.sessionId));
401
- const entry = this.context.cacheBySession.get(input.sessionId);
402
- return success("read", startedAt, {
403
- cacheRevision: entry?.revision ?? session.revision,
404
- entries: entry ? [cloneCacheEntrySnapshot(entry)] : []
405
- }, {
406
- sessionId: session.sessionId,
407
- revision: session.revision
408
- });
409
- }
410
- refreshSession(session) {
411
- const entry = createCacheEntry(session, true);
412
- this.context.cacheBySession.set(session.sessionId, entry);
413
- return entry;
414
- }
415
- invalidateSession(session) {
416
- const entry = createCacheEntry(session, false);
417
- this.context.cacheBySession.set(session.sessionId, entry);
418
- return entry;
419
- }
420
- removeSession(sessionId) {
421
- this.context.cacheBySession.delete(sessionId);
422
- }
423
- };
424
- //#endregion
425
- //#region src/services/event-service.ts
426
- const DEFAULT_EVENT_RETENTION_LIMIT = 1e3;
427
- var EventService = class {
428
- constructor(context) {
429
- this.context = context;
430
- }
431
- emit(event) {
432
- const emitted = {
433
- ...event,
434
- sequence: this.context.nextEventSequence(),
435
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
436
- diagnostics: event.diagnostics ?? []
437
- };
438
- const sessionId = event.sessionId;
439
- if (!sessionId) return emitted;
440
- const events = this.context.eventsBySession.get(sessionId) ?? [];
441
- events.push(emitted);
442
- while (events.length > DEFAULT_EVENT_RETENTION_LIMIT) events.shift();
443
- this.context.eventsBySession.set(sessionId, events);
444
- return emitted;
445
- }
446
- getEvents(input) {
447
- const startedAt = Date.now();
448
- const session = this.context.sessions.get(input.sessionId);
449
- if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
450
- const events = this.context.eventsBySession.get(input.sessionId) ?? [];
451
- const oldestSequence = events[0]?.sequence ?? (events.length === 0 ? 1 : 0);
452
- const currentSequence = events.at(-1)?.sequence ?? 0;
453
- const after = input.after === void 0 ? 0 : Number(input.after);
454
- if (!Number.isInteger(after) || after < 0) return failure("runtime", startedAt, {
455
- code: "event_cursor_invalid",
456
- message: `Invalid event cursor: ${input.after}`,
457
- sessionId: input.sessionId,
458
- after: String(input.after)
459
- });
460
- if (events.length > 0 && after < oldestSequence - 1) return failure("runtime", startedAt, {
461
- code: "event_cursor_invalid",
462
- message: `Event cursor has expired: ${after}`,
463
- sessionId: input.sessionId,
464
- after: String(input.after)
465
- });
466
- if (after > currentSequence) return failure("runtime", startedAt, {
467
- code: "event_cursor_invalid",
468
- message: `Unknown event cursor: ${after}`,
469
- sessionId: input.sessionId,
470
- after: String(input.after)
471
- });
472
- const filtered = events.filter((event) => event.sequence > after);
473
- const limit = input.limit === void 0 ? filtered.length : Math.max(0, input.limit);
474
- return success("runtime", startedAt, {
475
- events: filtered.slice(0, limit).map(cloneEventSnapshot),
476
- oldestSequence,
477
- currentSequence,
478
- cursorExpired: false
479
- }, {
480
- sessionId: session.sessionId,
481
- revision: session.revision
482
- });
483
- }
484
- removeSession(sessionId) {
485
- this.context.eventsBySession.delete(sessionId);
486
- }
487
- };
488
- //#endregion
489
- //#region src/services/job-service.ts
490
- const COMPLETED_JOB_RETENTION_LIMIT = 100;
491
- const REFRESH_START_DELAY_MS = 0;
492
- const REFRESH_COMPLETE_DELAY_MS = 50;
493
- function isTerminal(status) {
494
- return status === "completed" || status === "failed" || status === "cancelled";
495
- }
496
- var JobService = class {
497
- constructor(context, cacheService, eventService) {
498
- this.context = context;
499
- this.cacheService = cacheService;
500
- this.eventService = eventService;
501
- }
502
- cancellationRequests = /* @__PURE__ */ new Set();
503
- getJobs(sessionId) {
504
- return this.context.jobsBySession.get(sessionId) ?? [];
505
- }
506
- setJobs(sessionId, jobs) {
507
- const retained = [];
508
- let terminalCount = 0;
509
- for (let index = jobs.length - 1; index >= 0; index -= 1) {
510
- const job = jobs[index];
511
- if (isTerminal(job.status)) {
512
- if (terminalCount >= COMPLETED_JOB_RETENTION_LIMIT) continue;
513
- terminalCount += 1;
514
- }
515
- retained.push(job);
516
- }
517
- this.context.jobsBySession.set(sessionId, retained.reverse());
518
- }
519
- refreshCache(input) {
520
- const startedAt = Date.now();
521
- const session = this.context.sessions.get(input.sessionId);
522
- if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
523
- const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
524
- const job = {
525
- jobId,
526
- kind: "cache.refresh",
527
- status: "queued",
528
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
529
- sessionId: session.sessionId,
530
- canonicalPathKey: session.canonicalPathKey,
531
- revision: session.revision,
532
- diagnostics: [],
533
- progress: {
534
- completed: 0,
535
- total: 1,
536
- message: input.reason ?? "manual"
537
- }
538
- };
539
- const jobs = this.getJobs(session.sessionId);
540
- jobs.push(job);
541
- this.setJobs(session.sessionId, jobs);
542
- this.eventService.emit({
543
- kind: "job.created",
544
- sessionId: session.sessionId,
545
- canonicalPathKey: session.canonicalPathKey,
546
- revision: session.revision,
547
- jobId
548
- });
549
- this.scheduleRefreshJob(session.sessionId, jobId);
550
- return success("runtime", startedAt, cloneJobSnapshot(job), {
551
- sessionId: session.sessionId,
552
- revision: session.revision
553
- });
554
- }
555
- getJob(input) {
556
- const startedAt = Date.now();
557
- const session = this.context.sessions.get(input.sessionId);
558
- if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
559
- const job = this.getJobs(input.sessionId).find((item) => item.jobId === input.jobId);
560
- if (!job) return failure("runtime", startedAt, {
561
- code: "job_not_found",
562
- message: `Job was not found: ${input.jobId}`,
563
- sessionId: input.sessionId,
564
- jobId: input.jobId
565
- }, void 0, {
566
- sessionId: session.sessionId,
567
- revision: session.revision
568
- });
569
- return success("runtime", startedAt, cloneJobSnapshot(job), {
570
- sessionId: session.sessionId,
571
- revision: session.revision
572
- });
573
- }
574
- listJobs(input) {
575
- const startedAt = Date.now();
576
- const session = this.context.sessions.get(input.sessionId);
577
- if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
578
- let jobs = [...this.getJobs(input.sessionId)];
579
- if (input.kind) jobs = jobs.filter((job) => job.kind === input.kind);
580
- if (input.status) if (input.status === "active") jobs = jobs.filter((job) => !isTerminal(job.status));
581
- else if (input.status === "terminal") jobs = jobs.filter((job) => isTerminal(job.status));
582
- else jobs = jobs.filter((job) => job.status === input.status);
583
- if (input.limit !== void 0) jobs = jobs.slice(0, Math.max(0, input.limit));
584
- return success("runtime", startedAt, { jobs: jobs.map(cloneJobSnapshot) }, {
585
- sessionId: session.sessionId,
586
- revision: session.revision
587
- });
588
- }
589
- cancelJob(input) {
590
- const startedAt = Date.now();
591
- const session = this.context.sessions.get(input.sessionId);
592
- if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
593
- const job = this.getJobs(input.sessionId).find((item) => item.jobId === input.jobId);
594
- if (!job) return failure("runtime", startedAt, {
595
- code: "job_not_found",
596
- message: `Job was not found: ${input.jobId}`,
597
- sessionId: input.sessionId,
598
- jobId: input.jobId
599
- }, void 0, {
600
- sessionId: session.sessionId,
601
- revision: session.revision
602
- });
603
- if (isTerminal(job.status)) return failure("runtime", startedAt, {
604
- code: "job_not_cancellable",
605
- message: `Job is already terminal: ${input.jobId}`,
606
- sessionId: input.sessionId,
607
- jobId: input.jobId,
608
- status: job.status
609
- }, void 0, {
610
- sessionId: session.sessionId,
611
- revision: session.revision
612
- });
613
- this.cancellationRequests.add(job.jobId);
614
- this.eventService.emit({
615
- kind: "job.cancelRequested",
616
- sessionId: input.sessionId,
617
- canonicalPathKey: session.canonicalPathKey,
618
- revision: session.revision,
619
- jobId: job.jobId
620
- });
621
- return success("runtime", startedAt, cloneJobSnapshot(this.cancelRefreshJob(session.sessionId, job)), {
622
- sessionId: session.sessionId,
623
- revision: session.revision
624
- });
625
- }
626
- removeSession(sessionId) {
627
- for (const job of this.getJobs(sessionId)) this.cancellationRequests.delete(job.jobId);
628
- this.context.jobsBySession.delete(sessionId);
629
- }
630
- replaceJob(sessionId, nextJob) {
631
- const jobs = this.getJobs(sessionId).map((job) => job.jobId === nextJob.jobId ? nextJob : job);
632
- this.setJobs(sessionId, jobs);
633
- }
634
- scheduleRefreshJob(sessionId, jobId) {
635
- setTimeout(() => this.startRefreshJob(sessionId, jobId), REFRESH_START_DELAY_MS);
636
- }
637
- startRefreshJob(sessionId, jobId) {
638
- const session = this.context.sessions.get(sessionId);
639
- if (!session || session.closed) return;
640
- const job = this.getJobs(sessionId).find((item) => item.jobId === jobId);
641
- if (!job || isTerminal(job.status)) return;
642
- if (this.cancellationRequests.has(jobId)) {
643
- this.cancelRefreshJob(sessionId, job);
644
- return;
645
- }
646
- const running = {
647
- ...job,
648
- status: "running",
649
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
650
- progress: {
651
- completed: 0,
652
- total: 1,
653
- message: "refreshing cache"
654
- }
655
- };
656
- this.replaceJob(sessionId, running);
657
- this.eventService.emit({
658
- kind: "job.started",
659
- sessionId,
660
- canonicalPathKey: session.canonicalPathKey,
661
- revision: session.revision,
662
- jobId
663
- });
664
- this.eventService.emit({
665
- kind: "job.progress",
666
- sessionId,
667
- canonicalPathKey: session.canonicalPathKey,
668
- revision: session.revision,
669
- jobId,
670
- payload: running.progress
671
- });
672
- setTimeout(() => this.completeRefreshJob(sessionId, jobId), REFRESH_COMPLETE_DELAY_MS);
673
- }
674
- completeRefreshJob(sessionId, jobId) {
675
- const session = this.context.sessions.get(sessionId);
676
- if (!session || session.closed) return;
677
- const job = this.getJobs(sessionId).find((item) => item.jobId === jobId);
678
- if (!job || isTerminal(job.status)) return;
679
- if (this.cancellationRequests.has(jobId)) {
680
- this.cancelRefreshJob(sessionId, job);
681
- return;
682
- }
683
- try {
684
- const entry = this.cacheService.refreshSession(session);
685
- const completed = {
686
- ...job,
687
- status: "completed",
688
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
689
- cacheRevision: entry.revision,
690
- progress: {
691
- completed: 1,
692
- total: 1,
693
- message: "cache refreshed"
694
- },
695
- result: { cacheRevision: entry.revision }
696
- };
697
- this.replaceJob(sessionId, completed);
698
- this.eventService.emit({
699
- kind: "job.completed",
700
- sessionId,
701
- canonicalPathKey: session.canonicalPathKey,
702
- revision: session.revision,
703
- cacheRevision: entry.revision,
704
- jobId
705
- });
706
- this.eventService.emit({
707
- kind: "cache.updated",
708
- sessionId,
709
- canonicalPathKey: session.canonicalPathKey,
710
- revision: session.revision,
711
- cacheRevision: entry.revision
712
- });
713
- } catch (error) {
714
- const failed = {
715
- ...job,
716
- status: "failed",
717
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
718
- error: {
719
- code: "cache_refresh_failed",
720
- message: error instanceof Error ? error.message : "Cache refresh failed",
721
- sessionId,
722
- jobId
723
- }
724
- };
725
- this.replaceJob(sessionId, failed);
726
- this.eventService.emit({
727
- kind: "job.failed",
728
- sessionId,
729
- canonicalPathKey: session.canonicalPathKey,
730
- revision: session.revision,
731
- jobId,
732
- diagnostics: failed.diagnostics
733
- });
734
- }
735
- }
736
- cancelRefreshJob(sessionId, job) {
737
- const session = this.context.sessions.get(sessionId);
738
- const cancelled = {
739
- ...job,
740
- status: "cancelled",
741
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
742
- error: {
743
- code: "job_cancelled",
744
- message: `Job was cancelled: ${job.jobId}`,
745
- sessionId,
746
- jobId: job.jobId
747
- }
748
- };
749
- this.replaceJob(sessionId, cancelled);
750
- this.cancellationRequests.delete(job.jobId);
751
- this.eventService.emit({
752
- kind: "job.cancelled",
753
- sessionId,
754
- canonicalPathKey: session?.canonicalPathKey ?? job.canonicalPathKey,
755
- revision: session?.revision ?? job.revision,
756
- jobId: job.jobId
757
- });
758
- return cancelled;
759
- }
760
- };
761
- //#endregion
762
- //#region src/services/read-service.ts
763
- var ReadService = class {
764
- constructor(context) {
765
- this.context = context;
766
- }
767
- getCapabilities() {
768
- return success("read", Date.now(), cloneCapabilitiesSnapshot(this.context.capabilities));
769
- }
770
- getSession(input) {
771
- const startedAt = Date.now();
772
- const session = this.context.sessions.get(input.sessionId);
773
- if (!session || session.closed) return failure("read", startedAt, createSessionNotFoundError(input.sessionId));
774
- return success("read", startedAt, toSessionSnapshot(session, this.context.capabilities), {
775
- sessionId: session.sessionId,
776
- revision: session.revision
777
- });
778
- }
779
- };
780
- //#endregion
781
- //#region src/services/runtime-service.ts
782
- function randomId() {
783
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
784
- }
785
- var RuntimeService = class {
786
- constructor(context, cacheService, eventService, jobService) {
787
- this.context = context;
788
- this.cacheService = cacheService;
789
- this.eventService = eventService;
790
- this.jobService = jobService;
791
- }
792
- async openSession(input) {
793
- const startedAt = Date.now();
794
- const { fairyPath, canonicalProjectPath, canonicalPathKey } = await resolveCanonicalProjectRoot(this.context.fileSystem, input.projectPath);
795
- const existingSessionId = this.context.sessionsByPath.get(canonicalPathKey);
796
- const lockFilePath = this.context.fileSystem.join(canonicalProjectPath, ".openfairygui.backend.lock");
797
- if (existingSessionId) return failure("runtime", startedAt, {
798
- code: "lock_conflict",
799
- kind: "in_process_session_exists",
800
- message: `Project is already open in this backend runtime: ${canonicalProjectPath}`,
801
- canonicalPathKey,
802
- holderSessionId: existingSessionId,
803
- lockFilePath
804
- });
805
- let advisoryLock = null;
33
+ if (absolute) return joined ? `/${joined}` : "/";
34
+ return joined || ".";
35
+ }
36
+ function joinStoragePath(...paths) {
37
+ return normalizeStoragePath(paths.filter((part) => part.length > 0).join("/"));
38
+ }
39
+ function dirnameStoragePath(filePath) {
40
+ const normalized = normalizeStoragePath(filePath);
41
+ if (normalized === "/" || normalized === ".") return ".";
42
+ const absolute = normalized.startsWith("/");
43
+ const parts = normalized.split("/").filter((part) => part.length > 0);
44
+ parts.pop();
45
+ if (parts.length === 0) return absolute ? "/" : ".";
46
+ return `${absolute ? "/" : ""}${parts.join("/")}`;
47
+ }
48
+ function statFromLike(stat) {
49
+ if (typeof stat.isFile === "function" && typeof stat.isDirectory === "function") return stat;
50
+ const kind = stat.kind ?? stat.type;
51
+ if (kind === "file" || kind === "directory") return new StorageFileStat(kind);
52
+ throw createPathError("EINVAL", "Storage stat must provide kind/type or isFile()/isDirectory().");
53
+ }
54
+ async function inferStat(storage, filePath) {
55
+ if (storage.stat) return statFromLike(await storage.stat(filePath));
56
+ try {
57
+ await storage.readdir(filePath);
58
+ return new StorageFileStat("directory");
59
+ } catch {}
60
+ try {
61
+ await storage.readFileRaw(filePath);
62
+ return new StorageFileStat("file");
63
+ } catch {
806
64
  try {
807
- advisoryLock = await this.context.fileSystem.openExclusive(lockFilePath);
808
- await advisoryLock.writeFile(JSON.stringify({
809
- pid: process.pid,
810
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
811
- canonicalPathKey
812
- }));
813
- await advisoryLock.close();
814
- const project = await readProjectAsUam(new NodeIO(), fairyPath);
815
- const sessionId = randomId();
816
- const session = {
817
- sessionId,
818
- fairyPath,
819
- canonicalProjectPath,
820
- canonicalPathKey,
821
- lockFilePath,
822
- project,
823
- revision: 0,
824
- lastSavedRevision: 0,
825
- dirty: false,
826
- lockHeld: true,
827
- closed: false
828
- };
829
- this.context.sessions.set(sessionId, session);
830
- this.context.sessionsByPath.set(canonicalPathKey, sessionId);
831
- this.cacheService.refreshSession(session);
832
- this.eventService.emit({
833
- kind: "session.opened",
834
- sessionId,
835
- canonicalPathKey,
836
- revision: session.revision
837
- });
838
- return success("runtime", startedAt, toSessionSnapshot(session, this.context.capabilities), {
839
- sessionId: session.sessionId,
840
- revision: session.revision
841
- });
842
- } catch (error) {
843
- if (error?.code === "EEXIST") return failure("runtime", startedAt, {
844
- code: "lock_conflict",
845
- kind: "advisory_lock_conflict",
846
- message: `Advisory lock already exists for project: ${canonicalProjectPath}`,
847
- canonicalPathKey,
848
- lockFilePath
849
- });
850
- if (advisoryLock) {
851
- await advisoryLock.close().catch(() => void 0);
852
- await this.context.fileSystem.unlink(lockFilePath).catch(() => void 0);
853
- }
854
- throw error;
65
+ await storage.readFile(filePath);
66
+ return new StorageFileStat("file");
67
+ } catch {
68
+ throw createPathError("ENOENT", `Storage path not found: ${filePath}`);
855
69
  }
856
70
  }
857
- async closeSession(input) {
858
- const startedAt = Date.now();
859
- const session = this.context.sessions.get(input.sessionId);
860
- if (!session || session.closed) return failure("runtime", startedAt, createSessionNotFoundError(input.sessionId));
861
- this.eventService.emit({
862
- kind: "session.closeRequested",
863
- sessionId: session.sessionId,
864
- canonicalPathKey: session.canonicalPathKey,
865
- revision: session.revision
866
- });
867
- await this.context.fileSystem.unlink(session.lockFilePath).catch(() => void 0);
868
- session.lockHeld = false;
869
- session.closed = true;
870
- this.context.sessions.delete(session.sessionId);
871
- this.context.sessionsByPath.delete(session.canonicalPathKey);
872
- this.cacheService.removeSession(session.sessionId);
873
- this.jobService.removeSession(session.sessionId);
874
- this.eventService.emit({
875
- kind: "session.closed",
876
- sessionId: session.sessionId,
877
- canonicalPathKey: session.canonicalPathKey,
878
- revision: session.revision
879
- });
880
- this.eventService.removeSession(session.sessionId);
881
- return success("runtime", startedAt, {
882
- sessionId: session.sessionId,
883
- closed: true
884
- }, {
885
- sessionId: session.sessionId,
886
- revision: session.revision
887
- });
888
- }
889
- };
890
- //#endregion
891
- //#region src/runtime.ts
892
- const BACKEND_METHODS = [
893
- "getCapabilities",
894
- "openSession",
895
- "getSession",
896
- "applyTransaction",
897
- "saveSession",
898
- "closeSession",
899
- "getEvents",
900
- "getJob",
901
- "listJobs",
902
- "cancelJob",
903
- "getCacheSnapshot",
904
- "refreshCache"
905
- ];
906
- function createCapabilities() {
907
- return {
908
- contractVersion: BACKEND_CONTRACT_VERSION,
909
- capabilitySchemaVersion: 2,
910
- transactionKernelOwner: "@openfairygui/core",
911
- appSeamOwner: "@openfairygui/functions",
912
- runtimeOwner: "@openfairygui/backend",
913
- methods: BACKEND_METHODS,
914
- read: {
915
- capabilitySnapshot: true,
916
- sessionSnapshot: true
917
- },
918
- authoring: {
919
- applyTransaction: true,
920
- saveSession: true,
921
- resourceKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.resourceKinds],
922
- nodeKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.nodeKinds],
923
- gearKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.gearKinds],
924
- unsupported: ["artifact.publish", "artifact.restore"]
925
- },
926
- artifact: createArtifactCapabilities(),
927
- compatibilityPolicy: BACKEND_COMPATIBILITY_POLICY,
928
- runtime: {
929
- sessionRuntime: true,
930
- advisoryLocking: true,
931
- coordinatedSave: true,
932
- atomicSave: false,
933
- staleRevisionProtection: true,
934
- pathPolicy: createRuntimePathPolicy(),
935
- events: {
936
- polling: true,
937
- subscriptions: false,
938
- retentionLimit: 1e3,
939
- sequenceScope: "runtime"
940
- },
941
- jobs: {
942
- inMemory: true,
943
- cooperativeCancel: true,
944
- persistent: false,
945
- supportedKinds: ["cache.refresh"],
946
- artifactJobs: false,
947
- completedRetentionLimit: 100
948
- },
949
- cache: {
950
- derivedReadOnly: true,
951
- keyedBy: "canonicalPathKey",
952
- sourceOfTruth: false,
953
- refreshMethod: "refreshCache"
954
- }
955
- }
956
- };
957
71
  }
958
- function createNodeBackendFileSystem() {
959
- return {
72
+ function createBackendStorageFileSystem(storage) {
73
+ const lockedPaths = /* @__PURE__ */ new Set();
74
+ const fileSystem = {
960
75
  stat(filePath) {
961
- return fs.stat(filePath);
76
+ return inferStat(storage, fileSystem.resolve(filePath));
962
77
  },
963
78
  readdir(dirPath) {
964
- return fs.readdir(dirPath);
79
+ return storage.readdir(fileSystem.resolve(dirPath));
965
80
  },
966
81
  readFile(filePath) {
967
- return fs.readFile(filePath, "utf-8");
82
+ return storage.readFile(fileSystem.resolve(filePath));
968
83
  },
969
- async readFileRaw(filePath) {
970
- const buffer = await fs.readFile(filePath);
971
- return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
84
+ readFileRaw(filePath) {
85
+ return storage.readFileRaw(fileSystem.resolve(filePath));
972
86
  },
973
87
  writeFile(filePath, content) {
974
- return fs.writeFile(filePath, content, "utf-8");
88
+ return storage.writeFile(fileSystem.resolve(filePath), content);
975
89
  },
976
90
  writeFileRaw(filePath, data) {
977
- return fs.writeFile(filePath, data);
91
+ return storage.writeFileRaw(fileSystem.resolve(filePath), data);
978
92
  },
979
- async mkdir(dirPath, options) {
980
- await fs.mkdir(dirPath, { recursive: options?.recursive ?? false });
93
+ mkdir(dirPath, options) {
94
+ return storage.mkdir(fileSystem.resolve(dirPath), options);
981
95
  },
982
- async resolvePath(filePath) {
96
+ async exists(filePath) {
97
+ if (storage.exists) return storage.exists(fileSystem.resolve(filePath));
983
98
  try {
984
- return await fs.realpath(filePath);
99
+ await fileSystem.stat(filePath);
100
+ return true;
985
101
  } catch {
986
- return path.resolve(filePath);
102
+ return false;
987
103
  }
988
104
  },
105
+ resolvePath(filePath) {
106
+ const resolved = fileSystem.resolve(filePath);
107
+ return storage.resolvePath ? storage.resolvePath(resolved) : Promise.resolve(resolved);
108
+ },
989
109
  async openExclusive(filePath) {
990
- const handle = await fs.open(filePath, "wx");
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;
991
115
  return {
992
- writeFile(content) {
993
- return handle.writeFile(content, "utf-8");
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);
994
120
  },
995
- close() {
996
- return handle.close();
121
+ async close() {
122
+ closed = true;
123
+ lockedPaths.delete(resolved);
997
124
  }
998
125
  };
999
126
  },
1000
127
  unlink(filePath) {
1001
- return fs.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().");
1002
132
  },
1003
133
  join(...paths) {
1004
- return path.join(...paths);
134
+ return storage.join ? storage.join(...paths) : joinStoragePath(...paths);
1005
135
  },
1006
136
  dirname(filePath) {
1007
- return path.dirname(filePath);
137
+ return storage.dirname ? storage.dirname(filePath) : dirnameStoragePath(filePath);
1008
138
  },
1009
139
  resolve(...paths) {
1010
- return path.resolve(...paths);
140
+ return storage.resolve ? storage.resolve(...paths) : joinStoragePath(...paths);
1011
141
  }
1012
142
  };
143
+ return fileSystem;
1013
144
  }
1014
- var BackendRuntime = class {
1015
- fileSystem;
1016
- capabilities;
1017
- sessions = /* @__PURE__ */ new Map();
1018
- sessionsByPath = /* @__PURE__ */ new Map();
1019
- eventsBySession = /* @__PURE__ */ new Map();
1020
- jobsBySession = /* @__PURE__ */ new Map();
1021
- cacheBySession = /* @__PURE__ */ new Map();
1022
- eventSequence = 0;
1023
- context;
1024
- readService;
1025
- runtimeService;
1026
- authoringService;
1027
- cacheService;
1028
- eventService;
1029
- jobService;
1030
- constructor(options = {}) {
1031
- this.fileSystem = options.fileSystem ?? createNodeBackendFileSystem();
1032
- this.capabilities = createCapabilities();
1033
- this.context = {
1034
- fileSystem: this.fileSystem,
1035
- capabilities: this.capabilities,
1036
- sessions: this.sessions,
1037
- sessionsByPath: this.sessionsByPath,
1038
- eventsBySession: this.eventsBySession,
1039
- jobsBySession: this.jobsBySession,
1040
- cacheBySession: this.cacheBySession,
1041
- nextEventSequence: () => {
1042
- this.eventSequence += 1;
1043
- return this.eventSequence;
1044
- }
1045
- };
1046
- this.readService = new ReadService(this.context);
1047
- this.eventService = new EventService(this.context);
1048
- this.cacheService = new CacheService(this.context);
1049
- this.jobService = new JobService(this.context, this.cacheService, this.eventService);
1050
- this.runtimeService = new RuntimeService(this.context, this.cacheService, this.eventService, this.jobService);
1051
- this.authoringService = new AuthoringService(this.context, this.cacheService, this.eventService);
1052
- }
1053
- getCapabilities() {
1054
- return this.readService.getCapabilities();
1055
- }
1056
- async openSession(input) {
1057
- return this.runtimeService.openSession(input);
1058
- }
1059
- getSession(input) {
1060
- return this.readService.getSession(input);
1061
- }
1062
- async applyTransaction(input) {
1063
- return this.authoringService.applyTransaction(input);
1064
- }
1065
- async saveSession(input) {
1066
- return this.authoringService.saveSession(input);
1067
- }
1068
- async closeSession(input) {
1069
- return this.runtimeService.closeSession(input);
1070
- }
1071
- getEvents(input) {
1072
- return this.eventService.getEvents(input);
1073
- }
1074
- getJob(input) {
1075
- return this.jobService.getJob(input);
1076
- }
1077
- listJobs(input) {
1078
- return this.jobService.listJobs(input);
1079
- }
1080
- cancelJob(input) {
1081
- return this.jobService.cancelJob(input);
1082
- }
1083
- getCacheSnapshot(input) {
1084
- return this.cacheService.getCacheSnapshot(input);
1085
- }
1086
- refreshCache(input) {
1087
- return this.jobService.refreshCache(input);
1088
- }
1089
- };
1090
145
  //#endregion
1091
- export { BACKEND_CAPABILITY_SCHEMA_VERSION, BACKEND_COMPATIBILITY_POLICY, BACKEND_CONTRACT_VERSION, BackendRuntime, createNodeBackendFileSystem };
146
+ export { BACKEND_CAPABILITY_SCHEMA_VERSION, BACKEND_COMPATIBILITY_POLICY, BACKEND_CONTRACT_VERSION, BackendRuntime, createBackendStorageFileSystem };