@objectstack/core 3.0.8 → 3.0.9
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +7 -0
- package/dist/index.cjs +396 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +231 -1
- package/dist/index.d.ts +231 -1
- package/dist/index.js +394 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +4 -0
- package/src/namespace-resolver.test.ts +130 -0
- package/src/namespace-resolver.ts +188 -0
- package/src/package-manager.test.ts +225 -0
- package/src/package-manager.ts +428 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.9",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Microkernel Core for ObjectStack",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"pino": "^10.3.1",
|
|
23
23
|
"pino-pretty": "^13.1.3",
|
|
24
24
|
"zod": "^4.3.6",
|
|
25
|
-
"@objectstack/spec": "3.0.
|
|
25
|
+
"@objectstack/spec": "3.0.9"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"pino": "^8.0.0"
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,10 @@ export * from './health-monitor.js';
|
|
|
31
31
|
export * from './hot-reload.js';
|
|
32
32
|
export * from './dependency-resolver.js';
|
|
33
33
|
|
|
34
|
+
// Export Phase 3 components - Package lifecycle management
|
|
35
|
+
export * from './namespace-resolver.js';
|
|
36
|
+
export * from './package-manager.js';
|
|
37
|
+
|
|
34
38
|
// Re-export contracts from @objectstack/spec for backward compatibility
|
|
35
39
|
export type {
|
|
36
40
|
Logger,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { NamespaceResolver } from './namespace-resolver.js';
|
|
3
|
+
import { createLogger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
describe('NamespaceResolver', () => {
|
|
6
|
+
let resolver: NamespaceResolver;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
const logger = createLogger({ level: 'silent' });
|
|
10
|
+
resolver = new NamespaceResolver(logger);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('register', () => {
|
|
14
|
+
it('should register namespaces for a package', () => {
|
|
15
|
+
resolver.register('pkg-a', ['objects.task', 'views.task_list']);
|
|
16
|
+
|
|
17
|
+
const registry = resolver.getRegistry();
|
|
18
|
+
expect(registry.size).toBe(2);
|
|
19
|
+
expect(registry.get('objects.task')?.packageId).toBe('pkg-a');
|
|
20
|
+
expect(registry.get('views.task_list')?.packageId).toBe('pkg-a');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should overwrite namespace when same package re-registers', () => {
|
|
24
|
+
resolver.register('pkg-a', ['objects.task']);
|
|
25
|
+
resolver.register('pkg-a', ['objects.task']);
|
|
26
|
+
|
|
27
|
+
expect(resolver.getRegistry().size).toBe(1);
|
|
28
|
+
expect(resolver.getRegistry().get('objects.task')?.packageId).toBe('pkg-a');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('unregister', () => {
|
|
33
|
+
it('should remove all namespaces for a package', () => {
|
|
34
|
+
resolver.register('pkg-a', ['objects.task', 'views.task_list']);
|
|
35
|
+
resolver.register('pkg-b', ['objects.project']);
|
|
36
|
+
|
|
37
|
+
const removed = resolver.unregister('pkg-a');
|
|
38
|
+
expect(removed).toEqual(['objects.task', 'views.task_list']);
|
|
39
|
+
expect(resolver.getRegistry().size).toBe(1);
|
|
40
|
+
expect(resolver.getRegistry().has('objects.project')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return empty array for unknown package', () => {
|
|
44
|
+
const removed = resolver.unregister('unknown');
|
|
45
|
+
expect(removed).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('checkAvailability', () => {
|
|
50
|
+
it('should report no conflicts for unused namespaces', () => {
|
|
51
|
+
const result = resolver.checkAvailability('pkg-a', ['objects.task']);
|
|
52
|
+
expect(result.available).toBe(true);
|
|
53
|
+
expect(result.conflicts).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should detect conflict with another package', () => {
|
|
57
|
+
resolver.register('pkg-a', ['objects.task']);
|
|
58
|
+
|
|
59
|
+
const result = resolver.checkAvailability('pkg-b', ['objects.task']);
|
|
60
|
+
expect(result.available).toBe(false);
|
|
61
|
+
expect(result.conflicts).toHaveLength(1);
|
|
62
|
+
expect(result.conflicts[0].namespace).toBe('objects.task');
|
|
63
|
+
expect(result.conflicts[0].existingPackageId).toBe('pkg-a');
|
|
64
|
+
expect(result.conflicts[0].incomingPackageId).toBe('pkg-b');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should not conflict with own namespaces', () => {
|
|
68
|
+
resolver.register('pkg-a', ['objects.task']);
|
|
69
|
+
|
|
70
|
+
const result = resolver.checkAvailability('pkg-a', ['objects.task']);
|
|
71
|
+
expect(result.available).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should provide suggestions for conflicts', () => {
|
|
75
|
+
resolver.register('pkg-a', ['objects.task']);
|
|
76
|
+
|
|
77
|
+
const result = resolver.checkAvailability('@myorg/plugin-crm', ['objects.task']);
|
|
78
|
+
expect(result.available).toBe(false);
|
|
79
|
+
expect(result.suggestions['objects.task']).toBeDefined();
|
|
80
|
+
expect(result.suggestions['objects.task']).toContain('crm');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('extractNamespaces', () => {
|
|
85
|
+
it('should extract namespaces from object-style metadata', () => {
|
|
86
|
+
const config = {
|
|
87
|
+
objects: { task: {}, project: {} },
|
|
88
|
+
views: { task_list: {} },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const ns = resolver.extractNamespaces(config);
|
|
92
|
+
expect(ns).toContain('objects.task');
|
|
93
|
+
expect(ns).toContain('objects.project');
|
|
94
|
+
expect(ns).toContain('views.task_list');
|
|
95
|
+
expect(ns).toHaveLength(3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should extract namespaces from array-style metadata', () => {
|
|
99
|
+
const config = {
|
|
100
|
+
objects: [{ name: 'task' }, { name: 'project' }],
|
|
101
|
+
flows: [{ name: 'approval_flow' }],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const ns = resolver.extractNamespaces(config);
|
|
105
|
+
expect(ns).toContain('objects.task');
|
|
106
|
+
expect(ns).toContain('objects.project');
|
|
107
|
+
expect(ns).toContain('flows.approval_flow');
|
|
108
|
+
expect(ns).toHaveLength(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should return empty array for empty config', () => {
|
|
112
|
+
const ns = resolver.extractNamespaces({});
|
|
113
|
+
expect(ns).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('getPackageNamespaces', () => {
|
|
118
|
+
it('should return namespaces for a specific package', () => {
|
|
119
|
+
resolver.register('pkg-a', ['objects.task', 'views.task_list']);
|
|
120
|
+
resolver.register('pkg-b', ['objects.project']);
|
|
121
|
+
|
|
122
|
+
expect(resolver.getPackageNamespaces('pkg-a')).toEqual(['objects.task', 'views.task_list']);
|
|
123
|
+
expect(resolver.getPackageNamespaces('pkg-b')).toEqual(['objects.project']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return empty for unknown package', () => {
|
|
127
|
+
expect(resolver.getPackageNamespaces('unknown')).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { ObjectLogger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Namespace entry representing an object/view/flow etc. registered by a package.
|
|
7
|
+
*/
|
|
8
|
+
export interface NamespaceEntry {
|
|
9
|
+
/** The namespace path (e.g. "objects.project_task", "views.task_list") */
|
|
10
|
+
namespace: string;
|
|
11
|
+
/** The package that owns this namespace */
|
|
12
|
+
packageId: string;
|
|
13
|
+
/** When this entry was registered */
|
|
14
|
+
registeredAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Result of a namespace conflict check.
|
|
19
|
+
*/
|
|
20
|
+
export interface NamespaceConflict {
|
|
21
|
+
/** The conflicting namespace path */
|
|
22
|
+
namespace: string;
|
|
23
|
+
/** The package that currently owns this namespace */
|
|
24
|
+
existingPackageId: string;
|
|
25
|
+
/** The package attempting to register the same namespace */
|
|
26
|
+
incomingPackageId: string;
|
|
27
|
+
/** A suggested alternative name to avoid the conflict */
|
|
28
|
+
suggestion?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Result of namespace availability check.
|
|
33
|
+
*/
|
|
34
|
+
export interface NamespaceCheckResult {
|
|
35
|
+
/** Whether all requested namespaces are available */
|
|
36
|
+
available: boolean;
|
|
37
|
+
/** List of conflicts detected */
|
|
38
|
+
conflicts: NamespaceConflict[];
|
|
39
|
+
/** Suggested alternatives for each conflict */
|
|
40
|
+
suggestions: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Namespace Resolver
|
|
45
|
+
*
|
|
46
|
+
* Manages namespace registration for installed packages and detects collisions
|
|
47
|
+
* during install-time. Each metadata item (object, view, flow, page, etc.)
|
|
48
|
+
* produces a namespace like `objects.<name>` or `views.<name>`.
|
|
49
|
+
*
|
|
50
|
+
* When a new package declares objects, views, or other metadata that would
|
|
51
|
+
* collide with an existing package's metadata, this resolver reports the
|
|
52
|
+
* conflicts and suggests prefixed alternatives.
|
|
53
|
+
*/
|
|
54
|
+
export class NamespaceResolver {
|
|
55
|
+
private logger: ObjectLogger;
|
|
56
|
+
private registry: Map<string, NamespaceEntry> = new Map();
|
|
57
|
+
|
|
58
|
+
constructor(logger: ObjectLogger) {
|
|
59
|
+
this.logger = logger.child({ component: 'NamespaceResolver' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register namespaces owned by a package.
|
|
64
|
+
*/
|
|
65
|
+
register(packageId: string, namespaces: string[]): void {
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
for (const ns of namespaces) {
|
|
68
|
+
if (this.registry.has(ns)) {
|
|
69
|
+
const existing = this.registry.get(ns)!;
|
|
70
|
+
if (existing.packageId !== packageId) {
|
|
71
|
+
this.logger.warn('Overwriting namespace entry', { namespace: ns, existing: existing.packageId, incoming: packageId });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.registry.set(ns, { namespace: ns, packageId, registeredAt: now });
|
|
75
|
+
this.logger.debug('Namespace registered', { namespace: ns, packageId });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unregister all namespaces belonging to a package.
|
|
81
|
+
*/
|
|
82
|
+
unregister(packageId: string): string[] {
|
|
83
|
+
const removed: string[] = [];
|
|
84
|
+
for (const [ns, entry] of this.registry) {
|
|
85
|
+
if (entry.packageId === packageId) {
|
|
86
|
+
this.registry.delete(ns);
|
|
87
|
+
removed.push(ns);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.logger.debug('Namespaces unregistered', { packageId, count: removed.length });
|
|
91
|
+
return removed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check whether a set of namespaces is available for a given package.
|
|
96
|
+
*/
|
|
97
|
+
checkAvailability(packageId: string, namespaces: string[]): NamespaceCheckResult {
|
|
98
|
+
const conflicts: NamespaceConflict[] = [];
|
|
99
|
+
const suggestions: Record<string, string> = {};
|
|
100
|
+
|
|
101
|
+
for (const ns of namespaces) {
|
|
102
|
+
const existing = this.registry.get(ns);
|
|
103
|
+
if (existing && existing.packageId !== packageId) {
|
|
104
|
+
const suggestion = this.suggestAlternative(ns, packageId);
|
|
105
|
+
conflicts.push({
|
|
106
|
+
namespace: ns,
|
|
107
|
+
existingPackageId: existing.packageId,
|
|
108
|
+
incomingPackageId: packageId,
|
|
109
|
+
suggestion,
|
|
110
|
+
});
|
|
111
|
+
suggestions[ns] = suggestion;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
available: conflicts.length === 0,
|
|
117
|
+
conflicts,
|
|
118
|
+
suggestions,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract namespace strings from a package's metadata definition.
|
|
124
|
+
*/
|
|
125
|
+
extractNamespaces(config: Record<string, unknown>): string[] {
|
|
126
|
+
const namespaces: string[] = [];
|
|
127
|
+
const categories = [
|
|
128
|
+
'objects', 'views', 'pages', 'flows', 'workflows',
|
|
129
|
+
'apps', 'dashboards', 'reports', 'actions', 'agents',
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
for (const category of categories) {
|
|
133
|
+
const items = config[category];
|
|
134
|
+
if (Array.isArray(items)) {
|
|
135
|
+
for (const item of items) {
|
|
136
|
+
const name = (item as Record<string, unknown>)?.name;
|
|
137
|
+
if (typeof name === 'string') {
|
|
138
|
+
namespaces.push(`${category}.${name}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else if (items && typeof items === 'object') {
|
|
142
|
+
for (const key of Object.keys(items as object)) {
|
|
143
|
+
namespaces.push(`${category}.${key}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return namespaces;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get all registered entries.
|
|
153
|
+
*/
|
|
154
|
+
getRegistry(): ReadonlyMap<string, NamespaceEntry> {
|
|
155
|
+
return this.registry;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get all namespaces belonging to a specific package.
|
|
160
|
+
*/
|
|
161
|
+
getPackageNamespaces(packageId: string): string[] {
|
|
162
|
+
const namespaces: string[] = [];
|
|
163
|
+
for (const [ns, entry] of this.registry) {
|
|
164
|
+
if (entry.packageId === packageId) {
|
|
165
|
+
namespaces.push(ns);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return namespaces;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Generate a prefixed alternative namespace to avoid conflicts.
|
|
173
|
+
*/
|
|
174
|
+
private suggestAlternative(ns: string, packageId: string): string {
|
|
175
|
+
// Extract the short package name for prefixing
|
|
176
|
+
const shortName = packageId
|
|
177
|
+
.replace(/^@[^/]+\//, '')
|
|
178
|
+
.replace(/^plugin-/, '')
|
|
179
|
+
.replace(/-/g, '_');
|
|
180
|
+
|
|
181
|
+
const parts = ns.split('.');
|
|
182
|
+
if (parts.length >= 2) {
|
|
183
|
+
// e.g. "objects.task" → "objects.crm_task"
|
|
184
|
+
return `${parts[0]}.${shortName}_${parts.slice(1).join('.')}`;
|
|
185
|
+
}
|
|
186
|
+
return `${shortName}_${ns}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PackageManager } from './package-manager.js';
|
|
3
|
+
import { createLogger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
describe('PackageManager', () => {
|
|
6
|
+
let manager: PackageManager;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
const logger = createLogger({ level: 'silent' });
|
|
10
|
+
manager = new PackageManager(logger, { platformVersion: '3.0.0' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('install', () => {
|
|
14
|
+
it('should install a package successfully', async () => {
|
|
15
|
+
const result = await manager.install('pkg-a', '1.0.0', {
|
|
16
|
+
objects: { task: { label: 'Task' } },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(result.success).toBe(true);
|
|
20
|
+
expect(result.packageId).toBe('pkg-a');
|
|
21
|
+
expect(result.version).toBe('1.0.0');
|
|
22
|
+
expect(manager.getPackage('pkg-a')).toBeDefined();
|
|
23
|
+
expect(manager.getPackage('pkg-a')?.status).toBe('installed');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should reject already installed package', async () => {
|
|
27
|
+
await manager.install('pkg-a', '1.0.0', {});
|
|
28
|
+
const result = await manager.install('pkg-a', '1.0.0', {});
|
|
29
|
+
|
|
30
|
+
expect(result.success).toBe(false);
|
|
31
|
+
expect(result.errorMessage).toContain('already installed');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should detect namespace conflicts', async () => {
|
|
35
|
+
await manager.install('pkg-a', '1.0.0', {
|
|
36
|
+
objects: { task: {} },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = await manager.install('pkg-b', '1.0.0', {
|
|
40
|
+
objects: { task: {} },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.success).toBe(false);
|
|
44
|
+
expect(result.namespaceConflicts).toHaveLength(1);
|
|
45
|
+
expect(result.namespaceConflicts[0].namespace).toBe('objects.task');
|
|
46
|
+
expect(result.namespaceConflicts[0].existingPackageId).toBe('pkg-a');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should reject incompatible platform version', async () => {
|
|
50
|
+
const result = await manager.install('pkg-a', '1.0.0', {
|
|
51
|
+
engine: { objectstack: '>=4.0.0' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.success).toBe(false);
|
|
55
|
+
expect(result.errorMessage).toContain('platform');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should reject missing dependencies', async () => {
|
|
59
|
+
const result = await manager.install('pkg-a', '1.0.0', {
|
|
60
|
+
dependencies: { 'pkg-b': '^1.0.0' },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.success).toBe(false);
|
|
64
|
+
expect(result.errorMessage).toContain('Missing dependencies');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should succeed when dependencies are installed', async () => {
|
|
68
|
+
await manager.install('pkg-b', '1.0.0', {});
|
|
69
|
+
const result = await manager.install('pkg-a', '1.0.0', {
|
|
70
|
+
dependencies: { 'pkg-b': '^1.0.0' },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.success).toBe(true);
|
|
74
|
+
expect(result.installedDependencies).toContain('pkg-b');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('uninstall', () => {
|
|
79
|
+
it('should uninstall a package', async () => {
|
|
80
|
+
await manager.install('pkg-a', '1.0.0', {});
|
|
81
|
+
const result = await manager.uninstall('pkg-a');
|
|
82
|
+
|
|
83
|
+
expect(result.success).toBe(true);
|
|
84
|
+
expect(manager.getPackage('pkg-a')).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should prevent uninstalling if dependents exist', async () => {
|
|
88
|
+
await manager.install('pkg-a', '1.0.0', {});
|
|
89
|
+
await manager.install('pkg-b', '1.0.0', {
|
|
90
|
+
dependencies: { 'pkg-a': '^1.0.0' },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = await manager.uninstall('pkg-a');
|
|
94
|
+
expect(result.success).toBe(false);
|
|
95
|
+
expect(result.errorMessage).toContain('depended upon');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should reject uninstalling unknown package', async () => {
|
|
99
|
+
const result = await manager.uninstall('unknown');
|
|
100
|
+
expect(result.success).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('upgrade', () => {
|
|
105
|
+
it('should upgrade a package and create snapshot', async () => {
|
|
106
|
+
await manager.install('pkg-a', '1.0.0', {
|
|
107
|
+
objects: { task: {} },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await manager.upgrade('pkg-a', '2.0.0', {
|
|
111
|
+
objects: { task: {}, project: {} },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
expect(result.fromVersion).toBe('1.0.0');
|
|
116
|
+
expect(result.toVersion).toBe('2.0.0');
|
|
117
|
+
expect(result.snapshot.previousVersion).toBe('1.0.0');
|
|
118
|
+
expect(manager.getPackage('pkg-a')?.version).toBe('2.0.0');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should reject upgrade for uninstalled package', async () => {
|
|
122
|
+
const result = await manager.upgrade('unknown', '2.0.0', {});
|
|
123
|
+
expect(result.success).toBe(false);
|
|
124
|
+
expect(result.errorMessage).toContain('not installed');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should detect namespace conflicts during upgrade', async () => {
|
|
128
|
+
await manager.install('pkg-a', '1.0.0', { objects: { task: {} } });
|
|
129
|
+
await manager.install('pkg-b', '1.0.0', { objects: { project: {} } });
|
|
130
|
+
|
|
131
|
+
// pkg-a upgrade tries to add objects.project which is owned by pkg-b
|
|
132
|
+
const result = await manager.upgrade('pkg-a', '2.0.0', {
|
|
133
|
+
objects: { task: {}, project: {} },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(false);
|
|
137
|
+
expect(result.errorMessage).toContain('Namespace conflicts');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should reject platform-incompatible upgrade', async () => {
|
|
141
|
+
await manager.install('pkg-a', '1.0.0', {});
|
|
142
|
+
|
|
143
|
+
const result = await manager.upgrade('pkg-a', '2.0.0', {
|
|
144
|
+
engine: { objectstack: '>=5.0.0' },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(result.success).toBe(false);
|
|
148
|
+
expect(result.errorMessage).toContain('platform');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('rollback', () => {
|
|
153
|
+
it('should rollback to pre-upgrade state', async () => {
|
|
154
|
+
await manager.install('pkg-a', '1.0.0', {
|
|
155
|
+
objects: { task: {} },
|
|
156
|
+
});
|
|
157
|
+
await manager.upgrade('pkg-a', '2.0.0', {
|
|
158
|
+
objects: { task: {}, project: {} },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await manager.rollback('pkg-a');
|
|
162
|
+
expect(result.success).toBe(true);
|
|
163
|
+
expect(result.restoredVersion).toBe('1.0.0');
|
|
164
|
+
expect(manager.getPackage('pkg-a')?.version).toBe('1.0.0');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should reject rollback without snapshot', async () => {
|
|
168
|
+
await manager.install('pkg-a', '1.0.0', {});
|
|
169
|
+
const result = await manager.rollback('pkg-a');
|
|
170
|
+
|
|
171
|
+
expect(result.success).toBe(false);
|
|
172
|
+
expect(result.errorMessage).toContain('No upgrade snapshot');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('listPackages', () => {
|
|
177
|
+
it('should list all installed packages', async () => {
|
|
178
|
+
await manager.install('pkg-a', '1.0.0', {});
|
|
179
|
+
await manager.install('pkg-b', '2.0.0', {});
|
|
180
|
+
|
|
181
|
+
const list = manager.listPackages();
|
|
182
|
+
expect(list).toHaveLength(2);
|
|
183
|
+
expect(list.map(p => p.packageId)).toContain('pkg-a');
|
|
184
|
+
expect(list.map(p => p.packageId)).toContain('pkg-b');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('checkNamespaces', () => {
|
|
189
|
+
it('should check namespace availability', async () => {
|
|
190
|
+
await manager.install('pkg-a', '1.0.0', {
|
|
191
|
+
objects: { task: {} },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = manager.checkNamespaces('pkg-b', {
|
|
195
|
+
objects: { task: {} },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result.available).toBe(false);
|
|
199
|
+
expect(result.conflicts).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should report available when no conflicts', () => {
|
|
203
|
+
const result = manager.checkNamespaces('pkg-a', {
|
|
204
|
+
objects: { task: {} },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.available).toBe(true);
|
|
208
|
+
expect(result.conflicts).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('resolveDependencies', () => {
|
|
213
|
+
it('should resolve dependencies in topological order', () => {
|
|
214
|
+
const packages = new Map([
|
|
215
|
+
['a', { dependencies: [] as string[] }],
|
|
216
|
+
['b', { dependencies: ['a'] }],
|
|
217
|
+
['c', { dependencies: ['a', 'b'] }],
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const order = manager.resolveDependencies(packages);
|
|
221
|
+
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
|
222
|
+
expect(order.indexOf('b')).toBeLessThan(order.indexOf('c'));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|