@axiom-lattice/gateway 2.1.112 → 2.1.113

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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Core orchestration service for tenant configuration export and import.
3
+ *
4
+ * @module @axiom-lattice/gateway/services/ExportImportService
5
+ */
6
+
7
+ import { ExportableEntityRegistry, DependencyResolver, IdRemapper } from '@axiom-lattice/core';
8
+ import type {
9
+ ExportBundle,
10
+ ExportableEntity,
11
+ ImportPreviewResult,
12
+ Resolution,
13
+ ImportApplyResult,
14
+ ImportEntityResult,
15
+ } from '@axiom-lattice/core';
16
+ import { randomUUID } from 'crypto';
17
+
18
+ interface JobEntry {
19
+ bundle: ExportBundle;
20
+ tenantId: string;
21
+ createdAt: number;
22
+ }
23
+
24
+ /** Ephemeral in-memory job store (jobs expire after TTL). */
25
+ const exportJobs = new Map<string, JobEntry>();
26
+
27
+ /** TTL: 1 hour */
28
+ const JOB_TTL_MS = 60 * 60 * 1000;
29
+
30
+ function cleanExpiredJobs(): void {
31
+ const now = Date.now();
32
+ for (const [id, entry] of exportJobs) {
33
+ if (now - entry.createdAt > JOB_TTL_MS) {
34
+ exportJobs.delete(id);
35
+ }
36
+ }
37
+ }
38
+
39
+ export class ExportImportService {
40
+ private registry = ExportableEntityRegistry.getInstance();
41
+
42
+ /**
43
+ * Preview an export: check which types would be auto-included, which dependencies are missing.
44
+ */
45
+ async exportPreview(
46
+ tenantId: string,
47
+ selectedTypes: string[],
48
+ ): Promise<{ missingDependencies: string[]; cascadeAdditions: string[] }> {
49
+ const allDefs = this.registry.getAll();
50
+
51
+ const expanded = DependencyResolver.expandCascade(allDefs, selectedTypes);
52
+ const cascadeAdditions = expanded.filter((t) => !selectedTypes.includes(t));
53
+
54
+ const deps = DependencyResolver.computeDependencies(allDefs, expanded);
55
+ const registeredTypes = new Set(allDefs.map((d) => d.entityType));
56
+ const missingDependencies = deps.missing.filter((t) => registeredTypes.has(t));
57
+
58
+ return { missingDependencies, cascadeAdditions };
59
+ }
60
+
61
+ /**
62
+ * Generate a full ExportBundle for the given tenant and entity types.
63
+ * Stores the bundle in-memory and returns a jobId for download.
64
+ */
65
+ async export(
66
+ tenantId: string,
67
+ entityTypes: string[],
68
+ entityIds?: Record<string, string[]>,
69
+ ): Promise<{ jobId: string; bundle: ExportBundle }> {
70
+ const allDefs = this.registry.getAll();
71
+ const expanded = DependencyResolver.expandCascade(allDefs, entityTypes);
72
+ const finalTypes = [...new Set([...expanded, ...entityTypes])];
73
+
74
+ const dependencyOrder = DependencyResolver.resolveOrder(
75
+ finalTypes.map((t) => this.registry.get(t)),
76
+ );
77
+
78
+ const entities: Record<string, ExportableEntity[]> = {};
79
+ let exportCounter = 0;
80
+
81
+ for (const type of dependencyOrder) {
82
+ const def = this.registry.get(type);
83
+ let raw = await def.listForExport(tenantId);
84
+
85
+ if (entityIds && entityIds[type] && entityIds[type].length > 0) {
86
+ const idSet = new Set(entityIds[type]);
87
+ raw = raw.filter((e) => idSet.has(e.data.id as string));
88
+ }
89
+
90
+ entities[type] = raw.map((e) => ({
91
+ ...e,
92
+ _exportId: `exp-${type}-${String(++exportCounter).padStart(3, '0')}`,
93
+ data: this.sanitizeEntityData(e.data),
94
+ }));
95
+ }
96
+
97
+ // Build reverse map: raw entity ID → _exportId
98
+ const rawIdToExportId = new Map<string, string>();
99
+ for (const [, entityList] of Object.entries(entities)) {
100
+ for (const entity of entityList) {
101
+ const rawId = entity.data.id as string;
102
+ if (rawId) {
103
+ rawIdToExportId.set(rawId, entity._exportId);
104
+ }
105
+ }
106
+ }
107
+
108
+ // Rewrite reference fields to @type/exportId format
109
+ for (const [type, entityList] of Object.entries(entities)) {
110
+ const def = this.registry.get(type);
111
+ const refFields = def.referenceFields || {};
112
+ for (const entity of entityList) {
113
+ for (const [fieldName, refEntityType] of Object.entries(refFields)) {
114
+ const rawValue = entity.data[fieldName];
115
+ if (typeof rawValue === 'string' && rawValue) {
116
+ const exportId = rawIdToExportId.get(rawValue);
117
+ if (exportId) {
118
+ entity.data[fieldName] = `@${refEntityType}/${exportId}`;
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ const bundle: ExportBundle = {
126
+ version: 1,
127
+ exportedAt: new Date().toISOString(),
128
+ sourceTenantId: tenantId,
129
+ entities,
130
+ dependencyOrder,
131
+ _warning: 'This file contains plaintext secrets (passwords, API keys, tokens). Store it securely and delete after import.',
132
+ };
133
+
134
+ const jobId = randomUUID();
135
+ exportJobs.set(jobId, { bundle, tenantId, createdAt: Date.now() });
136
+
137
+ return { jobId, bundle };
138
+ }
139
+
140
+ /**
141
+ * Retrieve a previously generated export bundle by jobId with tenant validation.
142
+ */
143
+ getExportJob(jobId: string, tenantId: string): ExportBundle | undefined {
144
+ cleanExpiredJobs();
145
+ const entry = exportJobs.get(jobId);
146
+ if (!entry || entry.tenantId !== tenantId) return undefined;
147
+ return entry.bundle;
148
+ }
149
+
150
+ /**
151
+ * Preview import: validate bundle and detect conflicts.
152
+ */
153
+ async previewImport(
154
+ tenantId: string,
155
+ bundle: ExportBundle,
156
+ ): Promise<ImportPreviewResult> {
157
+ this.validateBundle(bundle);
158
+
159
+ const allConflicts: ImportPreviewResult['conflicts'] = [];
160
+ const allInsertions: ImportPreviewResult['insertions'] = [];
161
+ const allErrors: ImportPreviewResult['errors'] = [];
162
+
163
+ for (const [entityType, entities] of Object.entries(bundle.entities)) {
164
+ if (entities.length === 0) continue;
165
+
166
+ try {
167
+ const def = this.registry.get(entityType);
168
+ const result = await def.previewImport(tenantId, entities);
169
+
170
+ allConflicts.push(...result.conflicts);
171
+ allInsertions.push(...result.insertions);
172
+ allErrors.push(...result.errors);
173
+ } catch (err) {
174
+ for (const entity of entities) {
175
+ allErrors.push({
176
+ _exportId: entity._exportId,
177
+ entityType,
178
+ error: `Preview failed: ${(err as Error).message}`,
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ return { conflicts: allConflicts, insertions: allInsertions, errors: allErrors };
185
+ }
186
+
187
+ /**
188
+ * Apply import: create entities in dependency order with ID remapping.
189
+ */
190
+ async applyImport(
191
+ tenantId: string,
192
+ bundle: ExportBundle,
193
+ resolutions: Record<string, Resolution>,
194
+ ): Promise<ImportApplyResult> {
195
+ this.validateBundle(bundle);
196
+
197
+ const results: ImportEntityResult[] = [];
198
+ const idMap: Record<string, string> = {};
199
+ // Track skill old id → new id for agent graphDefinition implicit refs
200
+ const skillIdRemap: Record<string, string> = {};
201
+
202
+ for (const entityType of bundle.dependencyOrder) {
203
+ const entities = bundle.entities[entityType];
204
+ if (!entities || entities.length === 0) continue;
205
+
206
+ const def = this.registry.get(entityType);
207
+ const remapper = new IdRemapper(idMap);
208
+
209
+ for (const entity of entities) {
210
+ const resolution = resolutions[entity._exportId];
211
+
212
+ if (resolution?.action === 'skip') {
213
+ results.push({ _exportId: entity._exportId, entityType, status: 'skipped' });
214
+ continue;
215
+ }
216
+
217
+ try {
218
+ const remappedData = remapper.remapReferences(entity.data) as Record<string, unknown>;
219
+
220
+ // Remap implicit skill references in agent graphDefinition.skillIds
221
+ if (entityType === 'agent' && Object.keys(skillIdRemap).length > 0) {
222
+ const skillRemapper = new IdRemapper({});
223
+ remappedData.graphDefinition = skillRemapper.remapSkillIds(
224
+ (remappedData.graphDefinition as Record<string, unknown>) || {},
225
+ skillIdRemap,
226
+ );
227
+ }
228
+
229
+ if (resolution?.action === 'rename' && resolution.newId) {
230
+ remappedData.id = resolution.newId;
231
+ }
232
+
233
+ const entityWithRemappedData = { ...entity, data: remappedData };
234
+ const { newId } = await def.applyImport(
235
+ tenantId,
236
+ entityWithRemappedData,
237
+ resolution ?? { _exportId: entity._exportId, action: 'overwrite' },
238
+ );
239
+
240
+ if (newId) {
241
+ idMap[entity._exportId] = newId;
242
+ // Track skill ID renames for agent graphDefinition remapping
243
+ if (entityType === 'skill' && newId) {
244
+ const oldSkillId = remappedData.id as string;
245
+ if (oldSkillId !== newId) {
246
+ skillIdRemap[oldSkillId] = newId;
247
+ }
248
+ }
249
+ results.push({
250
+ _exportId: entity._exportId,
251
+ entityType,
252
+ status: resolution?.action === 'overwrite' ? 'updated' : 'created',
253
+ newId,
254
+ });
255
+ }
256
+ } catch (err) {
257
+ results.push({
258
+ _exportId: entity._exportId,
259
+ entityType,
260
+ status: 'failed',
261
+ error: (err as Error).message,
262
+ });
263
+ }
264
+ }
265
+ }
266
+
267
+ return { results, idMap };
268
+ }
269
+
270
+ /**
271
+ * List all registered exportable types (for frontend).
272
+ */
273
+ listExportableTypes() {
274
+ return this.registry.listTypes();
275
+ }
276
+
277
+ private validateBundle(bundle: ExportBundle): void {
278
+ if (bundle.version !== 1) {
279
+ throw new Error(`Unsupported bundle version: ${bundle.version}. Expected: 1`);
280
+ }
281
+ if (!bundle.entities || typeof bundle.entities !== 'object') {
282
+ throw new Error('Invalid bundle: missing entities');
283
+ }
284
+ if (!Array.isArray(bundle.dependencyOrder)) {
285
+ throw new Error('Invalid bundle: missing dependencyOrder');
286
+ }
287
+ }
288
+
289
+ private sanitizeEntityData(data: Record<string, unknown>): Record<string, unknown> {
290
+ const sanitized = { ...data };
291
+ delete sanitized.tenantId;
292
+ delete sanitized.createdAt;
293
+ delete sanitized.updatedAt;
294
+ return sanitized;
295
+ }
296
+ }
@@ -0,0 +1,130 @@
1
+ import { ExportImportService } from '../ExportImportService';
2
+ import { ExportableEntityRegistry } from '@axiom-lattice/core';
3
+ import type { ExportableEntityDefinition, ExportableEntity } from '@axiom-lattice/core';
4
+
5
+ function makeDef(
6
+ entityType: string,
7
+ entities: ExportableEntity[] = [],
8
+ dependsOn: string[] = [],
9
+ ): ExportableEntityDefinition {
10
+ return {
11
+ entityType,
12
+ label: entityType,
13
+ category: 'core',
14
+ dependsOn,
15
+ cascadeParents: [],
16
+ listForExport: jest.fn().mockResolvedValue(entities),
17
+ previewImport: jest.fn().mockResolvedValue({ conflicts: [], insertions: [], errors: [] }),
18
+ applyImport: jest.fn().mockResolvedValue({ newId: 'new-id' }),
19
+ };
20
+ }
21
+
22
+ describe('ExportImportService', () => {
23
+ beforeEach(() => {
24
+ (ExportableEntityRegistry as any).instance = undefined;
25
+ const registry = ExportableEntityRegistry.getInstance();
26
+ const s1 = { _exportId: '', data: { id: 's1', name: 'Skill 1' } };
27
+ const s2 = { _exportId: '', data: { id: 's2', name: 'Skill 2' } };
28
+ registry.register(makeDef('skill', [s1, s2]));
29
+ registry.register(makeDef('agent', [], ['skill']));
30
+ registry.register(makeDef('binding', [], ['agent']));
31
+ });
32
+
33
+ it('generates a valid ExportBundle', async () => {
34
+ const svc = new ExportImportService();
35
+ const { bundle } = await svc.export('tenant-1', ['skill']);
36
+
37
+ expect(bundle.version).toBe(1);
38
+ expect(bundle.sourceTenantId).toBe('tenant-1');
39
+ expect(bundle.entities.skill).toHaveLength(2);
40
+ expect(bundle.entities.skill[0]._exportId).toMatch(/^exp-skill-0/);
41
+ expect(bundle.entities.skill[0].data.id).toBe('s1');
42
+ expect(bundle.entities.skill[0].data).not.toHaveProperty('tenantId');
43
+ expect(bundle.entities.skill[0].data).not.toHaveProperty('createdAt');
44
+ expect(bundle.entities.skill[0].data).not.toHaveProperty('updatedAt');
45
+ expect(bundle.dependencyOrder).toEqual(['skill']);
46
+ });
47
+
48
+ it('filters entities by entityIds', async () => {
49
+ const svc = new ExportImportService();
50
+ const { bundle } = await svc.export('tenant-1', ['skill'], { skill: ['s1'] });
51
+ expect(bundle.entities.skill).toHaveLength(1);
52
+ expect(bundle.entities.skill[0].data.id).toBe('s1');
53
+ });
54
+
55
+ it('exportPreview detects missing dependencies', async () => {
56
+ const svc = new ExportImportService();
57
+ const result = await svc.exportPreview('tenant-1', ['agent']);
58
+ expect(result.missingDependencies).toContain('skill');
59
+ expect(result.cascadeAdditions).toEqual([]);
60
+ });
61
+
62
+ it('export includes transitive deps when explicitly requested', async () => {
63
+ const svc = new ExportImportService();
64
+ const { bundle } = await svc.export('tenant-1', ['skill', 'agent']);
65
+ expect(bundle.dependencyOrder).toContain('skill');
66
+ expect(bundle.dependencyOrder).toContain('agent');
67
+ expect(bundle.dependencyOrder.indexOf('skill')).toBeLessThan(bundle.dependencyOrder.indexOf('agent'));
68
+ });
69
+
70
+ it('previewImport returns insertions for new entities', async () => {
71
+ const svc = new ExportImportService();
72
+ const bundle = {
73
+ version: 1 as const,
74
+ exportedAt: new Date().toISOString(),
75
+ sourceTenantId: 't1',
76
+ entities: {
77
+ skill: [{ _exportId: 'exp-s-1', data: { id: 's1', name: 'Skill' } }],
78
+ },
79
+ dependencyOrder: ['skill'],
80
+ };
81
+
82
+ await svc.previewImport('tenant-2', bundle);
83
+ const registry = ExportableEntityRegistry.getInstance();
84
+ expect(registry.get('skill').previewImport).toHaveBeenCalled();
85
+ });
86
+
87
+ it('throws on invalid bundle version', async () => {
88
+ const svc = new ExportImportService();
89
+ await expect(
90
+ svc.previewImport('tenant-2', {
91
+ version: 99 as any,
92
+ entities: {},
93
+ dependencyOrder: [],
94
+ exportedAt: '',
95
+ sourceTenantId: '',
96
+ }),
97
+ ).rejects.toThrow(/version/);
98
+ });
99
+
100
+ it('throws on missing entities field', async () => {
101
+ const svc = new ExportImportService();
102
+ await expect(
103
+ svc.previewImport('tenant-2', {
104
+ version: 1,
105
+ exportedAt: '',
106
+ sourceTenantId: '',
107
+ dependencyOrder: [],
108
+ } as any),
109
+ ).rejects.toThrow(/entities/);
110
+ });
111
+
112
+ it('getExportJob returns undefined for unknown jobId', () => {
113
+ const svc = new ExportImportService();
114
+ expect(svc.getExportJob('nonexistent', 'tenant-1')).toBeUndefined();
115
+ });
116
+
117
+ it('getExportJob returns bundle after export', async () => {
118
+ const svc = new ExportImportService();
119
+ const { jobId, bundle } = await svc.export('tenant-1', ['skill']);
120
+ const retrieved = svc.getExportJob(jobId, 'tenant-1');
121
+ expect(retrieved).toEqual(bundle);
122
+ });
123
+
124
+ it('listExportableTypes returns registered types', () => {
125
+ const svc = new ExportImportService();
126
+ const types = svc.listExportableTypes();
127
+ expect(types).toHaveLength(3);
128
+ expect(types.map((t) => t.entityType)).toEqual(['skill', 'agent', 'binding']);
129
+ });
130
+ });