@openfairygui/backend 0.2.0-alpha.0

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