@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/src/runtime.ts ADDED
@@ -0,0 +1,600 @@
1
+ import {
2
+ UAM_SUPPORTED_MATERIALIZATION_SCOPE,
3
+ type UamTransactionOperation,
4
+ } from '@openfairygui/core';
5
+ import type { ApplyUamTransactionAppError } from '@openfairygui/functions';
6
+ import fs from 'node:fs/promises';
7
+ import type { Stats } from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ BACKEND_CAPABILITY_SCHEMA_VERSION,
11
+ BACKEND_COMPATIBILITY_POLICY,
12
+ BACKEND_CONTRACT_VERSION,
13
+ type BackendResponseMeta,
14
+ } from './contracts.js';
15
+ import { createRuntimePathPolicy, type PathPolicyViolationError } from './path-policy.js';
16
+ import { createArtifactCapabilities } from './services/artifact-service.js';
17
+ import { AuthoringService } from './services/authoring-service.js';
18
+ import { CacheService } from './services/cache-service.js';
19
+ import type { BackendContext, BackendSessionState } from './services/context.js';
20
+ import { EventService } from './services/event-service.js';
21
+ import { JobService } from './services/job-service.js';
22
+ import { ReadService } from './services/read-service.js';
23
+ import { RuntimeService } from './services/runtime-service.js';
24
+
25
+ export interface BackendFileHandle {
26
+ writeFile(content: string): Promise<void>;
27
+ close(): Promise<void>;
28
+ }
29
+
30
+ export interface BackendFileSystem {
31
+ stat(filePath: string): Promise<Stats>;
32
+ readdir(dirPath: string): Promise<string[]>;
33
+ readFile(filePath: string): Promise<string>;
34
+ readFileRaw(filePath: string): Promise<Uint8Array>;
35
+ writeFile(filePath: string, content: string): Promise<void>;
36
+ writeFileRaw(filePath: string, data: Uint8Array): Promise<void>;
37
+ mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void>;
38
+ resolvePath(filePath: string): Promise<string>;
39
+ openExclusive(filePath: string): Promise<BackendFileHandle>;
40
+ unlink(filePath: string): Promise<void>;
41
+ join(...paths: string[]): string;
42
+ dirname(filePath: string): string;
43
+ resolve(...paths: string[]): string;
44
+ }
45
+
46
+ export interface BackendCapabilities {
47
+ contractVersion: typeof BACKEND_CONTRACT_VERSION;
48
+ capabilitySchemaVersion: typeof BACKEND_CAPABILITY_SCHEMA_VERSION;
49
+ transactionKernelOwner: '@openfairygui/core';
50
+ appSeamOwner: '@openfairygui/functions';
51
+ runtimeOwner: '@openfairygui/backend';
52
+ methods: readonly [
53
+ 'getCapabilities',
54
+ 'openSession',
55
+ 'getSession',
56
+ 'applyTransaction',
57
+ 'saveSession',
58
+ 'closeSession',
59
+ 'getEvents',
60
+ 'getJob',
61
+ 'listJobs',
62
+ 'cancelJob',
63
+ 'getCacheSnapshot',
64
+ 'refreshCache',
65
+ ];
66
+ read: {
67
+ capabilitySnapshot: true;
68
+ sessionSnapshot: true;
69
+ };
70
+ authoring: {
71
+ applyTransaction: true;
72
+ saveSession: true;
73
+ resourceKinds: readonly string[];
74
+ nodeKinds: readonly string[];
75
+ gearKinds: readonly string[];
76
+ unsupported: readonly ['artifact.publish', 'artifact.restore'];
77
+ };
78
+ artifact: {
79
+ publish: false;
80
+ restore: false;
81
+ status: 'deferred';
82
+ };
83
+ compatibilityPolicy: typeof BACKEND_COMPATIBILITY_POLICY;
84
+ runtime: {
85
+ sessionRuntime: true;
86
+ advisoryLocking: true;
87
+ coordinatedSave: true;
88
+ atomicSave: false;
89
+ staleRevisionProtection: true;
90
+ pathPolicy: {
91
+ canonicalization: 'realpath+normalized-casefold';
92
+ sessionIdentity: 'project-root';
93
+ saveTarget: 'opened-project-only';
94
+ outputTargets: 'deferred';
95
+ workspaceBoundary: 'project-root-only';
96
+ };
97
+ events: {
98
+ polling: true;
99
+ subscriptions: false;
100
+ retentionLimit: 1000;
101
+ sequenceScope: 'runtime';
102
+ };
103
+ jobs: {
104
+ inMemory: true;
105
+ cooperativeCancel: true;
106
+ persistent: false;
107
+ supportedKinds: readonly ['cache.refresh'];
108
+ artifactJobs: false;
109
+ completedRetentionLimit: 100;
110
+ };
111
+ cache: {
112
+ derivedReadOnly: true;
113
+ keyedBy: 'canonicalPathKey';
114
+ sourceOfTruth: false;
115
+ refreshMethod: 'refreshCache';
116
+ };
117
+ };
118
+ }
119
+
120
+ export interface BackendSessionSnapshot {
121
+ sessionId: string;
122
+ canonicalProjectPath: string;
123
+ revision: number;
124
+ lastSavedRevision: number;
125
+ dirty: boolean;
126
+ lockHeld: boolean;
127
+ capabilities: BackendCapabilities;
128
+ }
129
+
130
+ export interface BackendSuccess<T> {
131
+ ok: true;
132
+ meta: BackendResponseMeta;
133
+ data: T;
134
+ }
135
+
136
+ export interface BackendFailure<E extends BackendError = BackendError> {
137
+ ok: false;
138
+ meta: BackendResponseMeta;
139
+ error: E;
140
+ session?: BackendSessionSnapshot;
141
+ }
142
+
143
+ export type BackendResult<T, E extends BackendError = BackendError> =
144
+ | BackendSuccess<T>
145
+ | BackendFailure<E>;
146
+
147
+ export interface SessionNotFoundError {
148
+ code: 'session_not_found';
149
+ message: string;
150
+ sessionId: string;
151
+ }
152
+
153
+ export interface SessionStaleWriteError {
154
+ code: 'stale_write';
155
+ message: string;
156
+ sessionId: string;
157
+ canonicalPathKey: string;
158
+ expectedRevision: number;
159
+ actualRevision: number;
160
+ }
161
+
162
+ export interface InProcessLockConflictError {
163
+ code: 'lock_conflict';
164
+ kind: 'in_process_session_exists';
165
+ message: string;
166
+ canonicalPathKey: string;
167
+ holderSessionId: string;
168
+ lockFilePath?: string;
169
+ }
170
+
171
+ export interface AdvisoryLockConflictError {
172
+ code: 'lock_conflict';
173
+ kind: 'advisory_lock_conflict';
174
+ message: string;
175
+ canonicalPathKey: string;
176
+ holderSessionId?: string;
177
+ lockFilePath: string;
178
+ }
179
+
180
+ export interface SavePartialFailureError {
181
+ code: 'save_partial_failure';
182
+ message: string;
183
+ sessionId: string;
184
+ canonicalPathKey: string;
185
+ attemptedRevision: number;
186
+ lastSavedRevision: number;
187
+ committedPaths: string[];
188
+ failedPaths: string[];
189
+ diskMayBePartiallyUpdated: true;
190
+ }
191
+
192
+ export type BackendEventKind =
193
+ | 'session.opened'
194
+ | 'transaction.applied'
195
+ | 'transaction.rejected'
196
+ | 'save.started'
197
+ | 'save.completed'
198
+ | 'save.failed'
199
+ | 'session.closeRequested'
200
+ | 'session.closed'
201
+ | 'cache.invalidated'
202
+ | 'cache.updated'
203
+ | 'job.created'
204
+ | 'job.started'
205
+ | 'job.progress'
206
+ | 'job.cancelRequested'
207
+ | 'job.cancelled'
208
+ | 'job.completed'
209
+ | 'job.failed';
210
+
211
+ export interface BackendEvent {
212
+ sequence: number;
213
+ kind: BackendEventKind;
214
+ timestamp: string;
215
+ sessionId?: string;
216
+ canonicalPathKey?: string;
217
+ revision?: number;
218
+ cacheRevision?: number;
219
+ jobId?: string;
220
+ diagnostics: import('./contracts.js').BackendDiagnostic[];
221
+ payload?: unknown;
222
+ }
223
+
224
+ export interface GetEventsInput {
225
+ sessionId: string;
226
+ after?: string;
227
+ limit?: number;
228
+ }
229
+
230
+ export interface GetEventsSnapshot {
231
+ events: BackendEvent[];
232
+ oldestSequence: number;
233
+ currentSequence: number;
234
+ cursorExpired: boolean;
235
+ }
236
+
237
+ export interface EventCursorInvalidError {
238
+ code: 'event_cursor_invalid';
239
+ message: string;
240
+ sessionId: string;
241
+ after: string;
242
+ }
243
+
244
+ export type BackendJobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
245
+ export type BackendJobKind = 'cache.refresh';
246
+ export type BackendJobListStatusFilter = BackendJobStatus | 'active' | 'terminal';
247
+
248
+ export interface BackendJobProgress {
249
+ completed: number;
250
+ total?: number;
251
+ message?: string;
252
+ }
253
+
254
+ export interface BackendJobSnapshot {
255
+ jobId: string;
256
+ kind: BackendJobKind;
257
+ status: BackendJobStatus;
258
+ createdAt: string;
259
+ startedAt?: string;
260
+ finishedAt?: string;
261
+ sessionId?: string;
262
+ canonicalPathKey?: string;
263
+ revision?: number;
264
+ cacheRevision?: number;
265
+ diagnostics: import('./contracts.js').BackendDiagnostic[];
266
+ progress?: BackendJobProgress;
267
+ result?: unknown;
268
+ error?: BackendError;
269
+ }
270
+
271
+ export interface BackendJobListSnapshot {
272
+ jobs: BackendJobSnapshot[];
273
+ }
274
+
275
+ export interface GetJobInput {
276
+ sessionId: string;
277
+ jobId: string;
278
+ }
279
+
280
+ export interface ListJobsInput {
281
+ sessionId: string;
282
+ status?: BackendJobListStatusFilter;
283
+ kind?: BackendJobKind;
284
+ limit?: number;
285
+ }
286
+
287
+ export interface CancelJobInput {
288
+ sessionId: string;
289
+ jobId: string;
290
+ }
291
+
292
+ export interface BackendJobNotFoundError {
293
+ code: 'job_not_found';
294
+ message: string;
295
+ sessionId: string;
296
+ jobId: string;
297
+ }
298
+
299
+ export interface BackendJobNotCancellableError {
300
+ code: 'job_not_cancellable';
301
+ message: string;
302
+ sessionId: string;
303
+ jobId: string;
304
+ status: 'completed' | 'failed' | 'cancelled';
305
+ }
306
+
307
+ export interface BackendJobCancelledError {
308
+ code: 'job_cancelled';
309
+ message: string;
310
+ sessionId: string;
311
+ jobId: string;
312
+ }
313
+
314
+ export interface CacheRefreshFailedError {
315
+ code: 'cache_refresh_failed';
316
+ message: string;
317
+ sessionId: string;
318
+ jobId: string;
319
+ causeCode?: string;
320
+ }
321
+
322
+ export type BackendJobErrors =
323
+ | BackendJobNotFoundError
324
+ | BackendJobNotCancellableError
325
+ | BackendJobCancelledError
326
+ | CacheRefreshFailedError;
327
+
328
+ export interface BackendCacheSnapshot {
329
+ cacheRevision: number;
330
+ entries: BackendCacheEntry[];
331
+ }
332
+
333
+ export interface BackendCacheEntry {
334
+ canonicalPathKey: string;
335
+ sessionId?: string;
336
+ revision: number;
337
+ lastSavedRevision: number;
338
+ dirty: boolean;
339
+ valid: boolean;
340
+ indexedAt: string;
341
+ summary: {
342
+ resourceCount: number;
343
+ packageCount?: number;
344
+ diagnostics: import('./contracts.js').BackendDiagnostic[];
345
+ };
346
+ }
347
+
348
+ export interface GetCacheSnapshotInput {
349
+ sessionId: string;
350
+ }
351
+
352
+ export interface RefreshCacheInput {
353
+ sessionId: string;
354
+ reason?: 'manual' | 'session_open' | 'after_save';
355
+ }
356
+
357
+ export type BackendError =
358
+ | SessionNotFoundError
359
+ | SessionStaleWriteError
360
+ | InProcessLockConflictError
361
+ | AdvisoryLockConflictError
362
+ | SavePartialFailureError
363
+ | PathPolicyViolationError
364
+ | EventCursorInvalidError
365
+ | BackendJobNotFoundError
366
+ | BackendJobNotCancellableError
367
+ | BackendJobCancelledError
368
+ | CacheRefreshFailedError
369
+ | ApplyUamTransactionAppError;
370
+
371
+ export interface ApplySessionTransactionInput {
372
+ sessionId: string;
373
+ expectedRevision: number;
374
+ operations: UamTransactionOperation[];
375
+ }
376
+
377
+ export interface BackendRuntimeOptions {
378
+ fileSystem?: BackendFileSystem;
379
+ }
380
+
381
+ const BACKEND_METHODS = [
382
+ 'getCapabilities',
383
+ 'openSession',
384
+ 'getSession',
385
+ 'applyTransaction',
386
+ 'saveSession',
387
+ 'closeSession',
388
+ 'getEvents',
389
+ 'getJob',
390
+ 'listJobs',
391
+ 'cancelJob',
392
+ 'getCacheSnapshot',
393
+ 'refreshCache',
394
+ ] as const;
395
+
396
+ function createCapabilities(): BackendCapabilities {
397
+ return {
398
+ contractVersion: BACKEND_CONTRACT_VERSION,
399
+ capabilitySchemaVersion: BACKEND_CAPABILITY_SCHEMA_VERSION,
400
+ transactionKernelOwner: '@openfairygui/core',
401
+ appSeamOwner: '@openfairygui/functions',
402
+ runtimeOwner: '@openfairygui/backend',
403
+ methods: BACKEND_METHODS,
404
+ read: {
405
+ capabilitySnapshot: true,
406
+ sessionSnapshot: true,
407
+ },
408
+ authoring: {
409
+ applyTransaction: true,
410
+ saveSession: true,
411
+ resourceKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.resourceKinds],
412
+ nodeKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.nodeKinds],
413
+ gearKinds: [...UAM_SUPPORTED_MATERIALIZATION_SCOPE.gearKinds],
414
+ unsupported: ['artifact.publish', 'artifact.restore'],
415
+ },
416
+ artifact: createArtifactCapabilities(),
417
+ compatibilityPolicy: BACKEND_COMPATIBILITY_POLICY,
418
+ runtime: {
419
+ sessionRuntime: true,
420
+ advisoryLocking: true,
421
+ coordinatedSave: true,
422
+ atomicSave: false,
423
+ staleRevisionProtection: true,
424
+ pathPolicy: createRuntimePathPolicy(),
425
+ events: {
426
+ polling: true,
427
+ subscriptions: false,
428
+ retentionLimit: 1000,
429
+ sequenceScope: 'runtime',
430
+ },
431
+ jobs: {
432
+ inMemory: true,
433
+ cooperativeCancel: true,
434
+ persistent: false,
435
+ supportedKinds: ['cache.refresh'],
436
+ artifactJobs: false,
437
+ completedRetentionLimit: 100,
438
+ },
439
+ cache: {
440
+ derivedReadOnly: true,
441
+ keyedBy: 'canonicalPathKey',
442
+ sourceOfTruth: false,
443
+ refreshMethod: 'refreshCache',
444
+ },
445
+ },
446
+ };
447
+ }
448
+
449
+ export function createNodeBackendFileSystem(): BackendFileSystem {
450
+ return {
451
+ stat(filePath: string): Promise<Stats> {
452
+ return fs.stat(filePath);
453
+ },
454
+ readdir(dirPath: string): Promise<string[]> {
455
+ return fs.readdir(dirPath);
456
+ },
457
+ readFile(filePath: string): Promise<string> {
458
+ return fs.readFile(filePath, 'utf-8');
459
+ },
460
+ async readFileRaw(filePath: string): Promise<Uint8Array> {
461
+ const buffer = await fs.readFile(filePath);
462
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
463
+ },
464
+ writeFile(filePath: string, content: string): Promise<void> {
465
+ return fs.writeFile(filePath, content, 'utf-8');
466
+ },
467
+ writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
468
+ return fs.writeFile(filePath, data);
469
+ },
470
+ async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise<void> {
471
+ await fs.mkdir(dirPath, { recursive: options?.recursive ?? false });
472
+ },
473
+ async resolvePath(filePath: string): Promise<string> {
474
+ try {
475
+ return await fs.realpath(filePath);
476
+ } catch {
477
+ return path.resolve(filePath);
478
+ }
479
+ },
480
+ async openExclusive(filePath: string): Promise<BackendFileHandle> {
481
+ const handle = await fs.open(filePath, 'wx');
482
+ return {
483
+ writeFile(content: string): Promise<void> {
484
+ return handle.writeFile(content, 'utf-8');
485
+ },
486
+ close(): Promise<void> {
487
+ return handle.close();
488
+ },
489
+ };
490
+ },
491
+ unlink(filePath: string): Promise<void> {
492
+ return fs.unlink(filePath);
493
+ },
494
+ join(...paths: string[]): string {
495
+ return path.join(...paths);
496
+ },
497
+ dirname(filePath: string): string {
498
+ return path.dirname(filePath);
499
+ },
500
+ resolve(...paths: string[]): string {
501
+ return path.resolve(...paths);
502
+ },
503
+ };
504
+ }
505
+
506
+ export class BackendRuntime {
507
+ private readonly fileSystem: BackendFileSystem;
508
+ private readonly capabilities: BackendCapabilities;
509
+ private readonly sessions = new Map<string, BackendSessionState>();
510
+ private readonly sessionsByPath = new Map<string, string>();
511
+ private readonly eventsBySession = new Map<string, BackendEvent[]>();
512
+ private readonly jobsBySession = new Map<string, BackendJobSnapshot[]>();
513
+ private readonly cacheBySession = new Map<string, BackendCacheEntry>();
514
+ private eventSequence = 0;
515
+ private readonly context: BackendContext;
516
+ private readonly readService: ReadService;
517
+ private readonly runtimeService: RuntimeService;
518
+ private readonly authoringService: AuthoringService;
519
+ private readonly cacheService: CacheService;
520
+ private readonly eventService: EventService;
521
+ private readonly jobService: JobService;
522
+
523
+ public constructor(options: BackendRuntimeOptions = {}) {
524
+ this.fileSystem = options.fileSystem ?? createNodeBackendFileSystem();
525
+ this.capabilities = createCapabilities();
526
+ this.context = {
527
+ fileSystem: this.fileSystem,
528
+ capabilities: this.capabilities,
529
+ sessions: this.sessions,
530
+ sessionsByPath: this.sessionsByPath,
531
+ eventsBySession: this.eventsBySession,
532
+ jobsBySession: this.jobsBySession,
533
+ cacheBySession: this.cacheBySession,
534
+ nextEventSequence: () => {
535
+ this.eventSequence += 1;
536
+ return this.eventSequence;
537
+ },
538
+ };
539
+ this.readService = new ReadService(this.context);
540
+ this.eventService = new EventService(this.context);
541
+ this.cacheService = new CacheService(this.context);
542
+ this.jobService = new JobService(this.context, this.cacheService, this.eventService);
543
+ this.runtimeService = new RuntimeService(this.context, this.cacheService, this.eventService, this.jobService);
544
+ this.authoringService = new AuthoringService(this.context, this.cacheService, this.eventService);
545
+ }
546
+
547
+ public getCapabilities(): BackendSuccess<BackendCapabilities> {
548
+ return this.readService.getCapabilities() as BackendSuccess<BackendCapabilities>;
549
+ }
550
+
551
+ public async openSession(input: { projectPath: string }): Promise<BackendResult<BackendSessionSnapshot, InProcessLockConflictError | AdvisoryLockConflictError>> {
552
+ return this.runtimeService.openSession(input);
553
+ }
554
+
555
+ public getSession(input: { sessionId: string }): BackendResult<BackendSessionSnapshot, SessionNotFoundError> {
556
+ return this.readService.getSession(input);
557
+ }
558
+
559
+ public async applyTransaction(
560
+ input: ApplySessionTransactionInput,
561
+ ): Promise<BackendResult<BackendSessionSnapshot, SessionNotFoundError | SessionStaleWriteError | ApplyUamTransactionAppError>> {
562
+ return this.authoringService.applyTransaction(input);
563
+ }
564
+
565
+ public async saveSession(
566
+ input: { sessionId: string; expectedRevision?: number; targetPath?: string },
567
+ ): Promise<BackendResult<BackendSessionSnapshot, SessionNotFoundError | SessionStaleWriteError | SavePartialFailureError | PathPolicyViolationError>> {
568
+ return this.authoringService.saveSession(input);
569
+ }
570
+
571
+ public async closeSession(
572
+ input: { sessionId: string },
573
+ ): Promise<BackendResult<{ sessionId: string; closed: true }, SessionNotFoundError>> {
574
+ return this.runtimeService.closeSession(input);
575
+ }
576
+
577
+ public getEvents(input: GetEventsInput): BackendResult<GetEventsSnapshot, SessionNotFoundError | EventCursorInvalidError> {
578
+ return this.eventService.getEvents(input);
579
+ }
580
+
581
+ public getJob(input: GetJobInput): BackendResult<BackendJobSnapshot, SessionNotFoundError | BackendJobNotFoundError> {
582
+ return this.jobService.getJob(input);
583
+ }
584
+
585
+ public listJobs(input: ListJobsInput): BackendResult<BackendJobListSnapshot, SessionNotFoundError> {
586
+ return this.jobService.listJobs(input);
587
+ }
588
+
589
+ public cancelJob(input: CancelJobInput): BackendResult<BackendJobSnapshot, SessionNotFoundError | BackendJobNotFoundError | BackendJobNotCancellableError> {
590
+ return this.jobService.cancelJob(input);
591
+ }
592
+
593
+ public getCacheSnapshot(input: GetCacheSnapshotInput): BackendResult<BackendCacheSnapshot, SessionNotFoundError> {
594
+ return this.cacheService.getCacheSnapshot(input);
595
+ }
596
+
597
+ public refreshCache(input: RefreshCacheInput): BackendResult<BackendJobSnapshot, SessionNotFoundError> {
598
+ return this.jobService.refreshCache(input);
599
+ }
600
+ }
@@ -0,0 +1,9 @@
1
+ import type { BackendCapabilities } from '../runtime.js';
2
+
3
+ export function createArtifactCapabilities(): BackendCapabilities['artifact'] {
4
+ return {
5
+ publish: false,
6
+ restore: false,
7
+ status: 'deferred',
8
+ };
9
+ }