@objectstack/metadata 3.3.0 → 4.0.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 +2197 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +42 -82
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +2201 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +65 -0
- package/dist/node.d.ts +65 -0
- package/dist/{index.mjs → node.js} +3 -1
- package/package.json +22 -17
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -504
- package/ROADMAP.md +0 -224
- package/src/index.ts +0 -68
- package/src/loaders/database-loader.test.ts +0 -559
- package/src/loaders/database-loader.ts +0 -352
- package/src/loaders/filesystem-loader.ts +0 -420
- package/src/loaders/loader-interface.ts +0 -89
- package/src/loaders/memory-loader.ts +0 -103
- package/src/loaders/remote-loader.ts +0 -140
- package/src/metadata-manager.ts +0 -1168
- package/src/metadata-service.test.ts +0 -965
- package/src/metadata.test.ts +0 -431
- package/src/migration/executor.ts +0 -54
- package/src/migration/index.ts +0 -3
- package/src/node-metadata-manager.ts +0 -126
- package/src/node.ts +0 -11
- package/src/objects/sys-metadata.object.ts +0 -188
- package/src/plugin.ts +0 -102
- package/src/serializers/json-serializer.ts +0 -73
- package/src/serializers/serializer-interface.ts +0 -65
- package/src/serializers/serializers.test.ts +0 -74
- package/src/serializers/typescript-serializer.ts +0 -127
- package/src/serializers/yaml-serializer.ts +0 -49
- package/tsconfig.json +0 -9
- package/vitest.config.ts +0 -23
- /package/dist/{index.d.mts → index.d.cts} +0 -0
- /package/dist/{index.mjs.map → node.js.map} +0 -0
|
@@ -1,965 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
-
import { MetadataManager } from './metadata-manager';
|
|
5
|
-
import { MemoryLoader } from './loaders/memory-loader';
|
|
6
|
-
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
|
|
7
|
-
import type { MetadataOverlay } from '@objectstack/spec/kernel';
|
|
8
|
-
|
|
9
|
-
// Suppress logger output during tests
|
|
10
|
-
vi.mock('@objectstack/core', () => ({
|
|
11
|
-
createLogger: () => ({
|
|
12
|
-
info: vi.fn(),
|
|
13
|
-
warn: vi.fn(),
|
|
14
|
-
error: vi.fn(),
|
|
15
|
-
debug: vi.fn(),
|
|
16
|
-
}),
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
describe('MetadataManager — IMetadataService Contract', () => {
|
|
20
|
-
let manager: MetadataManager;
|
|
21
|
-
let memoryLoader: MemoryLoader;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
memoryLoader = new MemoryLoader();
|
|
25
|
-
manager = new MetadataManager({
|
|
26
|
-
formats: ['json'],
|
|
27
|
-
loaders: [memoryLoader],
|
|
28
|
-
});
|
|
29
|
-
manager.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// ==========================================
|
|
33
|
-
// Core CRUD Operations
|
|
34
|
-
// ==========================================
|
|
35
|
-
|
|
36
|
-
describe('register / get', () => {
|
|
37
|
-
it('should register and retrieve a metadata item', async () => {
|
|
38
|
-
await manager.register('object', 'account', { name: 'account', label: 'Account' });
|
|
39
|
-
const result = await manager.get('object', 'account');
|
|
40
|
-
expect(result).toEqual({ name: 'account', label: 'Account' });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('should return undefined for non-existent item', async () => {
|
|
44
|
-
const result = await manager.get('object', 'nonexistent');
|
|
45
|
-
expect(result).toBeUndefined();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should overwrite existing item on re-register', async () => {
|
|
49
|
-
await manager.register('object', 'account', { name: 'account', label: 'V1' });
|
|
50
|
-
await manager.register('object', 'account', { name: 'account', label: 'V2' });
|
|
51
|
-
const result = await manager.get('object', 'account');
|
|
52
|
-
expect(result).toEqual({ name: 'account', label: 'V2' });
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should fall back to loaders when not in registry', async () => {
|
|
56
|
-
await memoryLoader.save('object', 'contact', { name: 'contact', label: 'Contact' });
|
|
57
|
-
const result = await manager.get('object', 'contact');
|
|
58
|
-
expect(result).toEqual({ name: 'contact', label: 'Contact' });
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('list (IMetadataService)', () => {
|
|
63
|
-
it('should return all items from registry and loaders', async () => {
|
|
64
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
65
|
-
await memoryLoader.save('object', 'contact', { name: 'contact' });
|
|
66
|
-
const items = await manager.list('object');
|
|
67
|
-
expect(items).toHaveLength(2);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('should deduplicate items between registry and loaders', async () => {
|
|
71
|
-
await manager.register('object', 'account', { name: 'account', label: 'Registry' });
|
|
72
|
-
await memoryLoader.save('object', 'account', { name: 'account', label: 'Loader' });
|
|
73
|
-
const items = await manager.list('object');
|
|
74
|
-
expect(items).toHaveLength(1);
|
|
75
|
-
// Registry takes precedence
|
|
76
|
-
expect((items[0] as any).label).toBe('Registry');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('should return empty array for unknown type', async () => {
|
|
80
|
-
const items = await manager.list('unknown_type');
|
|
81
|
-
expect(items).toEqual([]);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe('unregister', () => {
|
|
86
|
-
it('should remove an item from the registry', async () => {
|
|
87
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
88
|
-
await manager.unregister('object', 'account');
|
|
89
|
-
const result = await manager.get('object', 'account');
|
|
90
|
-
expect(result).toBeUndefined();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('should not throw when unregistering non-existent item', async () => {
|
|
94
|
-
await expect(manager.unregister('object', 'nonexistent')).resolves.not.toThrow();
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('exists', () => {
|
|
99
|
-
it('should find items in registry', async () => {
|
|
100
|
-
await manager.register('view', 'my_view', { name: 'my_view' });
|
|
101
|
-
expect(await manager.exists('view', 'my_view')).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('should find items in loaders', async () => {
|
|
105
|
-
await memoryLoader.save('object', 'task', { name: 'task' });
|
|
106
|
-
expect(await manager.exists('object', 'task')).toBe(true);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('should return false for non-existent items', async () => {
|
|
110
|
-
expect(await manager.exists('object', 'nope')).toBe(false);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('listNames', () => {
|
|
115
|
-
it('should return names from both registry and loaders', async () => {
|
|
116
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
117
|
-
await memoryLoader.save('object', 'contact', {});
|
|
118
|
-
const names = await manager.listNames('object');
|
|
119
|
-
expect(names).toContain('account');
|
|
120
|
-
expect(names).toContain('contact');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should deduplicate names', async () => {
|
|
124
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
125
|
-
await memoryLoader.save('object', 'account', {});
|
|
126
|
-
const names = await manager.listNames('object');
|
|
127
|
-
expect(names.filter(n => n === 'account')).toHaveLength(1);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe('getObject / listObjects', () => {
|
|
132
|
-
it('getObject should be shorthand for get("object", name)', async () => {
|
|
133
|
-
await manager.register('object', 'account', { name: 'account', label: 'Account' });
|
|
134
|
-
const result = await manager.getObject('account');
|
|
135
|
-
expect(result).toEqual({ name: 'account', label: 'Account' });
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('listObjects should be shorthand for list("object")', async () => {
|
|
139
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
140
|
-
await manager.register('object', 'contact', { name: 'contact' });
|
|
141
|
-
const items = await manager.listObjects();
|
|
142
|
-
expect(items).toHaveLength(2);
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// ==========================================
|
|
147
|
-
// UI Convenience Methods
|
|
148
|
-
// ==========================================
|
|
149
|
-
|
|
150
|
-
describe('UI convenience methods', () => {
|
|
151
|
-
it('should get a view via getView()', async () => {
|
|
152
|
-
const viewDef = { name: 'account_list', object: 'account', type: 'grid' };
|
|
153
|
-
await manager.register('view', 'account_list', viewDef);
|
|
154
|
-
|
|
155
|
-
const result = await manager.getView('account_list');
|
|
156
|
-
expect(result).toEqual(viewDef);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('should list views via listViews()', async () => {
|
|
160
|
-
await manager.register('view', 'account_list', { name: 'account_list', object: 'account' });
|
|
161
|
-
await manager.register('view', 'contact_list', { name: 'contact_list', object: 'contact' });
|
|
162
|
-
|
|
163
|
-
const allViews = await manager.listViews();
|
|
164
|
-
expect(allViews).toHaveLength(2);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('should filter views by object via listViews(object)', async () => {
|
|
168
|
-
await manager.register('view', 'account_list', { name: 'account_list', object: 'account' });
|
|
169
|
-
await manager.register('view', 'contact_list', { name: 'contact_list', object: 'contact' });
|
|
170
|
-
|
|
171
|
-
const accountViews = await manager.listViews('account');
|
|
172
|
-
expect(accountViews).toHaveLength(1);
|
|
173
|
-
expect((accountViews[0] as any).name).toBe('account_list');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('should get a dashboard via getDashboard()', async () => {
|
|
177
|
-
const dashDef = { name: 'sales', label: 'Sales Overview' };
|
|
178
|
-
await manager.register('dashboard', 'sales', dashDef);
|
|
179
|
-
|
|
180
|
-
const result = await manager.getDashboard('sales');
|
|
181
|
-
expect(result).toEqual(dashDef);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('should list dashboards via listDashboards()', async () => {
|
|
185
|
-
await manager.register('dashboard', 'sales', { name: 'sales' });
|
|
186
|
-
await manager.register('dashboard', 'ops', { name: 'ops' });
|
|
187
|
-
|
|
188
|
-
const dashboards = await manager.listDashboards();
|
|
189
|
-
expect(dashboards).toHaveLength(2);
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// ==========================================
|
|
194
|
-
// Package Management
|
|
195
|
-
// ==========================================
|
|
196
|
-
|
|
197
|
-
describe('unregisterPackage', () => {
|
|
198
|
-
it('should remove all items from a package', async () => {
|
|
199
|
-
await manager.register('object', 'crm_account', { name: 'crm_account', packageId: 'com.acme.crm' });
|
|
200
|
-
await manager.register('object', 'crm_contact', { name: 'crm_contact', packageId: 'com.acme.crm' });
|
|
201
|
-
await manager.register('object', 'sys_user', { name: 'sys_user', packageId: 'com.objectstack.core' });
|
|
202
|
-
|
|
203
|
-
await manager.unregisterPackage('com.acme.crm');
|
|
204
|
-
|
|
205
|
-
expect(await manager.get('object', 'crm_account')).toBeUndefined();
|
|
206
|
-
expect(await manager.get('object', 'crm_contact')).toBeUndefined();
|
|
207
|
-
expect(await manager.get('object', 'sys_user')).toBeDefined();
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// ==========================================
|
|
212
|
-
// Query / Search
|
|
213
|
-
// ==========================================
|
|
214
|
-
|
|
215
|
-
describe('query', () => {
|
|
216
|
-
beforeEach(async () => {
|
|
217
|
-
await manager.register('object', 'account', { name: 'account', label: 'Account' });
|
|
218
|
-
await manager.register('object', 'contact', { name: 'contact', label: 'Contact' });
|
|
219
|
-
await manager.register('view', 'account_list', { name: 'account_list', label: 'Account List' });
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it('should return all items when no filters', async () => {
|
|
223
|
-
const result = await manager.query({});
|
|
224
|
-
expect(result.total).toBeGreaterThanOrEqual(3);
|
|
225
|
-
expect(result.page).toBe(1);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('should filter by type', async () => {
|
|
229
|
-
const result = await manager.query({ types: ['object'] });
|
|
230
|
-
expect(result.total).toBe(2);
|
|
231
|
-
expect(result.items.every(i => i.type === 'object')).toBe(true);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('should support search', async () => {
|
|
235
|
-
const result = await manager.query({ search: 'account' });
|
|
236
|
-
expect(result.total).toBeGreaterThanOrEqual(1);
|
|
237
|
-
expect(result.items.every(i =>
|
|
238
|
-
i.name.includes('account') || (i.label && i.label.toLowerCase().includes('account'))
|
|
239
|
-
)).toBe(true);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('should support pagination', async () => {
|
|
243
|
-
const result = await manager.query({ types: ['object'], page: 1, pageSize: 1 });
|
|
244
|
-
expect(result.items).toHaveLength(1);
|
|
245
|
-
expect(result.total).toBe(2);
|
|
246
|
-
expect(result.page).toBe(1);
|
|
247
|
-
expect(result.pageSize).toBe(1);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('should support sorting', async () => {
|
|
251
|
-
const asc = await manager.query({ types: ['object'], sortBy: 'name', sortOrder: 'asc' });
|
|
252
|
-
expect(asc.items[0].name).toBe('account');
|
|
253
|
-
|
|
254
|
-
const desc = await manager.query({ types: ['object'], sortBy: 'name', sortOrder: 'desc' });
|
|
255
|
-
expect(desc.items[0].name).toBe('contact');
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
// ==========================================
|
|
260
|
-
// Bulk Operations
|
|
261
|
-
// ==========================================
|
|
262
|
-
|
|
263
|
-
describe('bulkRegister', () => {
|
|
264
|
-
it('should register multiple items at once', async () => {
|
|
265
|
-
const result = await manager.bulkRegister([
|
|
266
|
-
{ type: 'object', name: 'account', data: { name: 'account' } },
|
|
267
|
-
{ type: 'object', name: 'contact', data: { name: 'contact' } },
|
|
268
|
-
{ type: 'view', name: 'account_list', data: { name: 'account_list' } },
|
|
269
|
-
]);
|
|
270
|
-
|
|
271
|
-
expect(result.total).toBe(3);
|
|
272
|
-
expect(result.succeeded).toBe(3);
|
|
273
|
-
expect(result.failed).toBe(0);
|
|
274
|
-
|
|
275
|
-
expect(await manager.get('object', 'account')).toBeDefined();
|
|
276
|
-
expect(await manager.get('view', 'account_list')).toBeDefined();
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe('bulkUnregister', () => {
|
|
281
|
-
it('should unregister multiple items at once', async () => {
|
|
282
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
283
|
-
await manager.register('object', 'contact', { name: 'contact' });
|
|
284
|
-
|
|
285
|
-
const result = await manager.bulkUnregister([
|
|
286
|
-
{ type: 'object', name: 'account' },
|
|
287
|
-
{ type: 'object', name: 'contact' },
|
|
288
|
-
]);
|
|
289
|
-
|
|
290
|
-
expect(result.total).toBe(2);
|
|
291
|
-
expect(result.succeeded).toBe(2);
|
|
292
|
-
expect(await manager.get('object', 'account')).toBeUndefined();
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
// ==========================================
|
|
297
|
-
// Overlay / Customization
|
|
298
|
-
// ==========================================
|
|
299
|
-
|
|
300
|
-
describe('overlay management', () => {
|
|
301
|
-
const testOverlay: MetadataOverlay = {
|
|
302
|
-
id: 'overlay-1',
|
|
303
|
-
baseType: 'object',
|
|
304
|
-
baseName: 'account',
|
|
305
|
-
scope: 'platform',
|
|
306
|
-
patch: { label: 'Custom Account' },
|
|
307
|
-
active: true,
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
it('should save and retrieve an overlay', async () => {
|
|
311
|
-
await manager.saveOverlay(testOverlay);
|
|
312
|
-
const result = await manager.getOverlay('object', 'account', 'platform');
|
|
313
|
-
expect(result).toEqual(testOverlay);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it('should return undefined for missing overlay', async () => {
|
|
317
|
-
const result = await manager.getOverlay('object', 'nonexistent');
|
|
318
|
-
expect(result).toBeUndefined();
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('should remove an overlay', async () => {
|
|
322
|
-
await manager.saveOverlay(testOverlay);
|
|
323
|
-
await manager.removeOverlay('object', 'account', 'platform');
|
|
324
|
-
const result = await manager.getOverlay('object', 'account', 'platform');
|
|
325
|
-
expect(result).toBeUndefined();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('should get effective metadata with overlays applied', async () => {
|
|
329
|
-
await manager.register('object', 'account', { name: 'account', label: 'Account', type: 'object' });
|
|
330
|
-
await manager.saveOverlay(testOverlay);
|
|
331
|
-
|
|
332
|
-
const effective = await manager.getEffective('object', 'account') as any;
|
|
333
|
-
expect(effective.label).toBe('Custom Account');
|
|
334
|
-
expect(effective.name).toBe('account');
|
|
335
|
-
expect(effective.type).toBe('object');
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it('should apply user overlay on top of platform overlay', async () => {
|
|
339
|
-
await manager.register('object', 'account', { name: 'account', label: 'Account' });
|
|
340
|
-
|
|
341
|
-
await manager.saveOverlay({
|
|
342
|
-
id: 'platform-1',
|
|
343
|
-
baseType: 'object',
|
|
344
|
-
baseName: 'account',
|
|
345
|
-
scope: 'platform',
|
|
346
|
-
patch: { label: 'Platform Label', description: 'Platform Desc' },
|
|
347
|
-
active: true,
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
await manager.saveOverlay({
|
|
351
|
-
id: 'user-1',
|
|
352
|
-
baseType: 'object',
|
|
353
|
-
baseName: 'account',
|
|
354
|
-
scope: 'user',
|
|
355
|
-
patch: { label: 'User Label' },
|
|
356
|
-
active: true,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
const effective = await manager.getEffective('object', 'account') as any;
|
|
360
|
-
expect(effective.label).toBe('User Label');
|
|
361
|
-
expect(effective.description).toBe('Platform Desc');
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it('should not apply inactive overlays', async () => {
|
|
365
|
-
await manager.register('object', 'account', { name: 'account', label: 'Original' });
|
|
366
|
-
await manager.saveOverlay({
|
|
367
|
-
id: 'inactive-1',
|
|
368
|
-
baseType: 'object',
|
|
369
|
-
baseName: 'account',
|
|
370
|
-
scope: 'platform',
|
|
371
|
-
patch: { label: 'Should Not Apply' },
|
|
372
|
-
active: false,
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
const effective = await manager.getEffective('object', 'account') as any;
|
|
376
|
-
expect(effective.label).toBe('Original');
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it('should apply user overlay scoped to specific userId via getEffective context', async () => {
|
|
380
|
-
await manager.register('view', 'account_list', {
|
|
381
|
-
name: 'account_list',
|
|
382
|
-
columns: ['name', 'email', 'status']
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
// Platform overlay
|
|
386
|
-
await manager.saveOverlay({
|
|
387
|
-
id: 'platform-view-1',
|
|
388
|
-
baseType: 'view',
|
|
389
|
-
baseName: 'account_list',
|
|
390
|
-
scope: 'platform',
|
|
391
|
-
patch: { columns: ['name', 'email', 'status', 'created_at'] },
|
|
392
|
-
active: true,
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// User-specific overlay
|
|
396
|
-
await manager.saveOverlay({
|
|
397
|
-
id: 'user-view-1',
|
|
398
|
-
baseType: 'view',
|
|
399
|
-
baseName: 'account_list',
|
|
400
|
-
scope: 'user',
|
|
401
|
-
owner: 'user-456',
|
|
402
|
-
patch: { columns: ['name', 'status'] },
|
|
403
|
-
active: true,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// Without context — should apply platform but not user overlay (no owner match)
|
|
407
|
-
const general = await manager.getEffective('view', 'account_list') as any;
|
|
408
|
-
expect(general.columns).toEqual(['name', 'email', 'status', 'created_at']);
|
|
409
|
-
|
|
410
|
-
// With userId context — should apply user overlay
|
|
411
|
-
const forUser = await manager.getEffective('view', 'account_list', {
|
|
412
|
-
userId: 'user-456'
|
|
413
|
-
}) as any;
|
|
414
|
-
expect(forUser.columns).toEqual(['name', 'status']);
|
|
415
|
-
});
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
// ==========================================
|
|
419
|
-
// Watch / Subscribe (IMetadataService)
|
|
420
|
-
// ==========================================
|
|
421
|
-
|
|
422
|
-
describe('watchService', () => {
|
|
423
|
-
it('should return a handle with unsubscribe', () => {
|
|
424
|
-
const callback = vi.fn();
|
|
425
|
-
const handle = manager.watchService('object', callback);
|
|
426
|
-
expect(handle).toBeDefined();
|
|
427
|
-
expect(typeof handle.unsubscribe).toBe('function');
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
it('should invoke callback on notification', () => {
|
|
431
|
-
const callback = vi.fn();
|
|
432
|
-
manager.watchService('object', callback);
|
|
433
|
-
|
|
434
|
-
// Trigger via internal method
|
|
435
|
-
(manager as any).notifyWatchers('object', {
|
|
436
|
-
type: 'changed',
|
|
437
|
-
metadataType: 'object',
|
|
438
|
-
name: 'account',
|
|
439
|
-
path: '/fake',
|
|
440
|
-
timestamp: new Date().toISOString(),
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
expect(callback).toHaveBeenCalledOnce();
|
|
444
|
-
expect(callback).toHaveBeenCalledWith(
|
|
445
|
-
expect.objectContaining({ type: 'updated', metadataType: 'object', name: 'account' })
|
|
446
|
-
);
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it('should stop invoking after unsubscribe', () => {
|
|
450
|
-
const callback = vi.fn();
|
|
451
|
-
const handle = manager.watchService('object', callback);
|
|
452
|
-
handle.unsubscribe();
|
|
453
|
-
|
|
454
|
-
(manager as any).notifyWatchers('object', {
|
|
455
|
-
type: 'added',
|
|
456
|
-
metadataType: 'object',
|
|
457
|
-
name: 'account',
|
|
458
|
-
path: '/fake',
|
|
459
|
-
timestamp: new Date().toISOString(),
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
expect(callback).not.toHaveBeenCalled();
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
// ==========================================
|
|
467
|
-
// Import / Export
|
|
468
|
-
// ==========================================
|
|
469
|
-
|
|
470
|
-
describe('exportMetadata', () => {
|
|
471
|
-
it('should export all registered metadata', async () => {
|
|
472
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
473
|
-
await manager.register('view', 'account_list', { name: 'account_list' });
|
|
474
|
-
|
|
475
|
-
const bundle = await manager.exportMetadata() as Record<string, unknown[]>;
|
|
476
|
-
expect(bundle.object).toHaveLength(1);
|
|
477
|
-
expect(bundle.view).toHaveLength(1);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it('should filter by types', async () => {
|
|
481
|
-
await manager.register('object', 'account', { name: 'account' });
|
|
482
|
-
await manager.register('view', 'account_list', { name: 'account_list' });
|
|
483
|
-
|
|
484
|
-
const bundle = await manager.exportMetadata({ types: ['object'] }) as Record<string, unknown[]>;
|
|
485
|
-
expect(bundle.object).toHaveLength(1);
|
|
486
|
-
expect(bundle.view).toBeUndefined();
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
describe('importMetadata', () => {
|
|
491
|
-
it('should import metadata from bundle', async () => {
|
|
492
|
-
const bundle = {
|
|
493
|
-
object: [{ name: 'account', label: 'Account' }, { name: 'contact', label: 'Contact' }],
|
|
494
|
-
view: [{ name: 'account_list', label: 'Account List' }],
|
|
495
|
-
};
|
|
496
|
-
|
|
497
|
-
const result = await manager.importMetadata(bundle);
|
|
498
|
-
expect(result.total).toBe(3);
|
|
499
|
-
expect(result.imported).toBe(3);
|
|
500
|
-
expect(result.failed).toBe(0);
|
|
501
|
-
|
|
502
|
-
expect(await manager.get('object', 'account')).toBeDefined();
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
it('should skip existing items with skip strategy', async () => {
|
|
506
|
-
await manager.register('object', 'account', { name: 'account', label: 'Original' });
|
|
507
|
-
|
|
508
|
-
const bundle = {
|
|
509
|
-
object: [{ name: 'account', label: 'Imported' }],
|
|
510
|
-
};
|
|
511
|
-
|
|
512
|
-
const result = await manager.importMetadata(bundle, { conflictResolution: 'skip' });
|
|
513
|
-
expect(result.skipped).toBe(1);
|
|
514
|
-
expect(result.imported).toBe(0);
|
|
515
|
-
|
|
516
|
-
const item = await manager.get('object', 'account') as any;
|
|
517
|
-
expect(item.label).toBe('Original');
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
it('should overwrite existing items with overwrite strategy', async () => {
|
|
521
|
-
await manager.register('object', 'account', { name: 'account', label: 'Original' });
|
|
522
|
-
|
|
523
|
-
const bundle = {
|
|
524
|
-
object: [{ name: 'account', label: 'Overwritten' }],
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
const result = await manager.importMetadata(bundle, { conflictResolution: 'overwrite' });
|
|
528
|
-
expect(result.imported).toBe(1);
|
|
529
|
-
|
|
530
|
-
const item = await manager.get('object', 'account') as any;
|
|
531
|
-
expect(item.label).toBe('Overwritten');
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
it('should merge existing items with merge strategy', async () => {
|
|
535
|
-
await manager.register('object', 'account', { name: 'account', label: 'Original', type: 'object' });
|
|
536
|
-
|
|
537
|
-
const bundle = {
|
|
538
|
-
object: [{ name: 'account', label: 'Merged', description: 'New desc' }],
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
const result = await manager.importMetadata(bundle, { conflictResolution: 'merge' });
|
|
542
|
-
expect(result.imported).toBe(1);
|
|
543
|
-
|
|
544
|
-
const item = await manager.get('object', 'account') as any;
|
|
545
|
-
expect(item.label).toBe('Merged');
|
|
546
|
-
expect(item.type).toBe('object');
|
|
547
|
-
expect(item.description).toBe('New desc');
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it('should support dry run', async () => {
|
|
551
|
-
const bundle = {
|
|
552
|
-
object: [{ name: 'account', label: 'Account' }],
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
const result = await manager.importMetadata(bundle, { dryRun: true });
|
|
556
|
-
expect(result.imported).toBe(1);
|
|
557
|
-
|
|
558
|
-
// Should not actually register
|
|
559
|
-
expect(await manager.get('object', 'account')).toBeUndefined();
|
|
560
|
-
});
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
// ==========================================
|
|
564
|
-
// Validation
|
|
565
|
-
// ==========================================
|
|
566
|
-
|
|
567
|
-
describe('validate', () => {
|
|
568
|
-
it('should validate valid metadata', async () => {
|
|
569
|
-
const result = await manager.validate('object', { name: 'account', label: 'Account' });
|
|
570
|
-
expect(result.valid).toBe(true);
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
it('should reject null data', async () => {
|
|
574
|
-
const result = await manager.validate('object', null);
|
|
575
|
-
expect(result.valid).toBe(false);
|
|
576
|
-
expect(result.errors).toBeDefined();
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
it('should reject non-object data', async () => {
|
|
580
|
-
const result = await manager.validate('object', 'not-an-object');
|
|
581
|
-
expect(result.valid).toBe(false);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it('should reject data without name field', async () => {
|
|
585
|
-
const result = await manager.validate('object', { label: 'No Name' });
|
|
586
|
-
expect(result.valid).toBe(false);
|
|
587
|
-
expect(result.errors![0].path).toBe('name');
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it('should warn about missing label', async () => {
|
|
591
|
-
const result = await manager.validate('object', { name: 'account' });
|
|
592
|
-
expect(result.valid).toBe(true);
|
|
593
|
-
expect(result.warnings).toBeDefined();
|
|
594
|
-
expect(result.warnings!.some(w => w.path === 'label')).toBe(true);
|
|
595
|
-
});
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
// ==========================================
|
|
599
|
-
// Type Registry
|
|
600
|
-
// ==========================================
|
|
601
|
-
|
|
602
|
-
describe('type registry', () => {
|
|
603
|
-
it('should return all registered types', async () => {
|
|
604
|
-
const types = await manager.getRegisteredTypes();
|
|
605
|
-
expect(types).toContain('object');
|
|
606
|
-
expect(types).toContain('view');
|
|
607
|
-
expect(types).toContain('flow');
|
|
608
|
-
expect(types).toContain('agent');
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
it('should include custom types from registry', async () => {
|
|
612
|
-
await manager.register('custom_type', 'item1', { name: 'item1' });
|
|
613
|
-
const types = await manager.getRegisteredTypes();
|
|
614
|
-
expect(types).toContain('custom_type');
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
it('should return type info for known types', async () => {
|
|
618
|
-
const info = await manager.getTypeInfo('object');
|
|
619
|
-
expect(info).toBeDefined();
|
|
620
|
-
expect(info!.type).toBe('object');
|
|
621
|
-
expect(info!.label).toBe('Object');
|
|
622
|
-
expect(info!.domain).toBe('data');
|
|
623
|
-
expect(info!.supportsOverlay).toBe(true);
|
|
624
|
-
expect(info!.filePatterns).toBeDefined();
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it('should return undefined for unknown types', async () => {
|
|
628
|
-
const info = await manager.getTypeInfo('unknown_type');
|
|
629
|
-
expect(info).toBeUndefined();
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
// ==========================================
|
|
634
|
-
// Dependency Tracking
|
|
635
|
-
// ==========================================
|
|
636
|
-
|
|
637
|
-
describe('dependency tracking', () => {
|
|
638
|
-
it('should track and retrieve dependencies', async () => {
|
|
639
|
-
manager.addDependency({
|
|
640
|
-
sourceType: 'view',
|
|
641
|
-
sourceName: 'account_list',
|
|
642
|
-
targetType: 'object',
|
|
643
|
-
targetName: 'account',
|
|
644
|
-
kind: 'reference',
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
const deps = await manager.getDependencies('view', 'account_list');
|
|
648
|
-
expect(deps).toHaveLength(1);
|
|
649
|
-
expect(deps[0].targetType).toBe('object');
|
|
650
|
-
expect(deps[0].targetName).toBe('account');
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
it('should find dependents of a target', async () => {
|
|
654
|
-
manager.addDependency({
|
|
655
|
-
sourceType: 'view',
|
|
656
|
-
sourceName: 'account_list',
|
|
657
|
-
targetType: 'object',
|
|
658
|
-
targetName: 'account',
|
|
659
|
-
kind: 'reference',
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
manager.addDependency({
|
|
663
|
-
sourceType: 'flow',
|
|
664
|
-
sourceName: 'account_flow',
|
|
665
|
-
targetType: 'object',
|
|
666
|
-
targetName: 'account',
|
|
667
|
-
kind: 'triggers',
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
const dependents = await manager.getDependents('object', 'account');
|
|
671
|
-
expect(dependents).toHaveLength(2);
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
it('should return empty array when no dependencies', async () => {
|
|
675
|
-
expect(await manager.getDependencies('object', 'nonexistent')).toEqual([]);
|
|
676
|
-
expect(await manager.getDependents('object', 'nonexistent')).toEqual([]);
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
it('should not add duplicate dependencies', () => {
|
|
680
|
-
const dep = {
|
|
681
|
-
sourceType: 'view',
|
|
682
|
-
sourceName: 'account_list',
|
|
683
|
-
targetType: 'object',
|
|
684
|
-
targetName: 'account',
|
|
685
|
-
kind: 'reference' as const,
|
|
686
|
-
};
|
|
687
|
-
|
|
688
|
-
manager.addDependency(dep);
|
|
689
|
-
manager.addDependency(dep);
|
|
690
|
-
|
|
691
|
-
// Should only have one entry
|
|
692
|
-
const deps = manager.getDependencies('view', 'account_list');
|
|
693
|
-
return deps.then(result => expect(result).toHaveLength(1));
|
|
694
|
-
});
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
// ==========================================
|
|
698
|
-
// Package Publish / Revert / getPublished
|
|
699
|
-
// ==========================================
|
|
700
|
-
|
|
701
|
-
describe('publishPackage', () => {
|
|
702
|
-
it('should publish all items in a package', async () => {
|
|
703
|
-
await manager.register('object', 'opportunity', {
|
|
704
|
-
name: 'opportunity', label: 'Opportunity', packageId: 'com.acme.crm', state: 'draft',
|
|
705
|
-
metadata: { fields: ['name', 'amount'] },
|
|
706
|
-
});
|
|
707
|
-
await manager.register('view', 'opp_list', {
|
|
708
|
-
name: 'opp_list', label: 'Opp List', packageId: 'com.acme.crm', state: 'draft',
|
|
709
|
-
metadata: { columns: ['name', 'amount'] },
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
const result = await manager.publishPackage('com.acme.crm', { publishedBy: 'admin' });
|
|
713
|
-
|
|
714
|
-
expect(result.success).toBe(true);
|
|
715
|
-
expect(result.packageId).toBe('com.acme.crm');
|
|
716
|
-
expect(result.version).toBe(1);
|
|
717
|
-
expect(result.itemsPublished).toBe(2);
|
|
718
|
-
expect(result.publishedAt).toBeDefined();
|
|
719
|
-
|
|
720
|
-
// Verify items are now active with published snapshots
|
|
721
|
-
const obj = await manager.get('object', 'opportunity') as any;
|
|
722
|
-
expect(obj.state).toBe('active');
|
|
723
|
-
expect(obj.publishedDefinition).toBeDefined();
|
|
724
|
-
expect(obj.publishedBy).toBe('admin');
|
|
725
|
-
expect(obj.publishedAt).toBeDefined();
|
|
726
|
-
|
|
727
|
-
const view = await manager.get('view', 'opp_list') as any;
|
|
728
|
-
expect(view.state).toBe('active');
|
|
729
|
-
expect(view.publishedDefinition).toBeDefined();
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
it('should increment version on each publish', async () => {
|
|
733
|
-
await manager.register('object', 'account', {
|
|
734
|
-
name: 'account', packageId: 'crm', state: 'draft', version: 0,
|
|
735
|
-
metadata: { fields: ['name'] },
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
const first = await manager.publishPackage('crm');
|
|
739
|
-
expect(first.version).toBe(1);
|
|
740
|
-
|
|
741
|
-
const second = await manager.publishPackage('crm');
|
|
742
|
-
expect(second.version).toBe(2);
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
it('should fail for empty package', async () => {
|
|
746
|
-
const result = await manager.publishPackage('nonexistent');
|
|
747
|
-
expect(result.success).toBe(false);
|
|
748
|
-
expect(result.itemsPublished).toBe(0);
|
|
749
|
-
expect(result.validationErrors).toBeDefined();
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
it('should fail validation when items are invalid', async () => {
|
|
753
|
-
// Register an item without a name (will fail validate)
|
|
754
|
-
await manager.register('object', 'bad_item', {
|
|
755
|
-
packageId: 'com.acme.bad', state: 'draft',
|
|
756
|
-
metadata: {},
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
const result = await manager.publishPackage('com.acme.bad', { validate: true });
|
|
760
|
-
expect(result.success).toBe(false);
|
|
761
|
-
expect(result.validationErrors).toBeDefined();
|
|
762
|
-
expect(result.validationErrors!.length).toBeGreaterThan(0);
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it('should skip validation when validate=false', async () => {
|
|
766
|
-
await manager.register('object', 'skip_val', {
|
|
767
|
-
packageId: 'com.acme.skip', state: 'draft',
|
|
768
|
-
metadata: {},
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
const result = await manager.publishPackage('com.acme.skip', { validate: false });
|
|
772
|
-
expect(result.success).toBe(true);
|
|
773
|
-
expect(result.itemsPublished).toBe(1);
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
it('should fail when dependency is not found or not published', async () => {
|
|
777
|
-
await manager.register('view', 'opp_list', {
|
|
778
|
-
name: 'opp_list', label: 'Opp List', packageId: 'com.acme.dep',
|
|
779
|
-
metadata: { columns: ['name'] },
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
// Register a dependency pointing to a non-existent item
|
|
783
|
-
manager.addDependency({
|
|
784
|
-
sourceType: 'view',
|
|
785
|
-
sourceName: 'opp_list',
|
|
786
|
-
targetType: 'object',
|
|
787
|
-
targetName: 'opportunity',
|
|
788
|
-
kind: 'reference',
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
const result = await manager.publishPackage('com.acme.dep', { validate: true });
|
|
792
|
-
expect(result.success).toBe(false);
|
|
793
|
-
expect(result.validationErrors).toBeDefined();
|
|
794
|
-
expect(result.validationErrors!.some(e => e.message.includes('opportunity'))).toBe(true);
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
it('should pass dependency check when target is in the same package', async () => {
|
|
798
|
-
await manager.register('object', 'project', {
|
|
799
|
-
name: 'project', label: 'Project', packageId: 'com.acme.same',
|
|
800
|
-
metadata: { fields: ['name'] },
|
|
801
|
-
});
|
|
802
|
-
await manager.register('view', 'project_list', {
|
|
803
|
-
name: 'project_list', label: 'Project List', packageId: 'com.acme.same',
|
|
804
|
-
metadata: { columns: ['name'] },
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
// Dependency within the same package
|
|
808
|
-
manager.addDependency({
|
|
809
|
-
sourceType: 'view',
|
|
810
|
-
sourceName: 'project_list',
|
|
811
|
-
targetType: 'object',
|
|
812
|
-
targetName: 'project',
|
|
813
|
-
kind: 'reference',
|
|
814
|
-
});
|
|
815
|
-
|
|
816
|
-
const result = await manager.publishPackage('com.acme.same', { validate: true });
|
|
817
|
-
expect(result.success).toBe(true);
|
|
818
|
-
expect(result.itemsPublished).toBe(2);
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
it('should pass dependency check when target is already published', async () => {
|
|
822
|
-
// Pre-existing published object (different package)
|
|
823
|
-
await manager.register('object', 'account', {
|
|
824
|
-
name: 'account', label: 'Account', packageId: 'com.acme.core',
|
|
825
|
-
publishedDefinition: { fields: ['name'] },
|
|
826
|
-
state: 'active',
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
// View in a different package references the published object
|
|
830
|
-
await manager.register('view', 'account_list', {
|
|
831
|
-
name: 'account_list', label: 'Account List', packageId: 'com.acme.views',
|
|
832
|
-
metadata: { columns: ['name'] },
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
manager.addDependency({
|
|
836
|
-
sourceType: 'view',
|
|
837
|
-
sourceName: 'account_list',
|
|
838
|
-
targetType: 'object',
|
|
839
|
-
targetName: 'account',
|
|
840
|
-
kind: 'reference',
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
const result = await manager.publishPackage('com.acme.views', { validate: true });
|
|
844
|
-
expect(result.success).toBe(true);
|
|
845
|
-
expect(result.itemsPublished).toBe(1);
|
|
846
|
-
});
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
describe('revertPackage', () => {
|
|
850
|
-
it('should revert to last published state', async () => {
|
|
851
|
-
// Register and publish
|
|
852
|
-
await manager.register('object', 'account', {
|
|
853
|
-
name: 'account', label: 'Account', packageId: 'crm',
|
|
854
|
-
metadata: { fields: ['name', 'email'] },
|
|
855
|
-
});
|
|
856
|
-
await manager.publishPackage('crm');
|
|
857
|
-
|
|
858
|
-
// Make edits after publish
|
|
859
|
-
const item = await manager.get('object', 'account') as any;
|
|
860
|
-
await manager.register('object', 'account', {
|
|
861
|
-
...item,
|
|
862
|
-
metadata: { fields: ['name', 'email', 'phone'] },
|
|
863
|
-
state: 'draft',
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
// Verify edit was saved
|
|
867
|
-
const edited = await manager.get('object', 'account') as any;
|
|
868
|
-
expect(edited.metadata.fields).toContain('phone');
|
|
869
|
-
|
|
870
|
-
// Revert
|
|
871
|
-
await manager.revertPackage('crm');
|
|
872
|
-
|
|
873
|
-
// Verify reverted to published state
|
|
874
|
-
const reverted = await manager.get('object', 'account') as any;
|
|
875
|
-
expect(reverted.state).toBe('active');
|
|
876
|
-
expect(reverted.metadata).toEqual(reverted.publishedDefinition);
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
it('should throw for non-existent package', async () => {
|
|
880
|
-
await expect(manager.revertPackage('nonexistent')).rejects.toThrow('No metadata items found');
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
it('should throw for never-published package', async () => {
|
|
884
|
-
await manager.register('object', 'new_item', {
|
|
885
|
-
name: 'new_item', packageId: 'com.acme.new',
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
await expect(manager.revertPackage('com.acme.new')).rejects.toThrow('has never been published');
|
|
889
|
-
});
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
describe('getPublished', () => {
|
|
893
|
-
it('should return published definition when available', async () => {
|
|
894
|
-
await manager.register('object', 'account', {
|
|
895
|
-
name: 'account', label: 'Account', packageId: 'crm',
|
|
896
|
-
metadata: { fields: ['name'] },
|
|
897
|
-
});
|
|
898
|
-
await manager.publishPackage('crm');
|
|
899
|
-
|
|
900
|
-
// Edit after publish
|
|
901
|
-
const item = await manager.get('object', 'account') as any;
|
|
902
|
-
await manager.register('object', 'account', {
|
|
903
|
-
...item,
|
|
904
|
-
metadata: { fields: ['name', 'email', 'phone'] },
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
// getPublished should return the published snapshot, not the edited version
|
|
908
|
-
const published = await manager.getPublished('object', 'account');
|
|
909
|
-
expect(published).toBeDefined();
|
|
910
|
-
// The published snapshot was taken from the original metadata
|
|
911
|
-
const pubAny = published as any;
|
|
912
|
-
expect(pubAny.fields).toBeDefined();
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it('should return current definition when never published', async () => {
|
|
916
|
-
await manager.register('object', 'contact', {
|
|
917
|
-
name: 'contact', label: 'Contact',
|
|
918
|
-
metadata: { fields: ['first_name'] },
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
const published = await manager.getPublished('object', 'contact');
|
|
922
|
-
expect(published).toBeDefined();
|
|
923
|
-
// Falls back to metadata field
|
|
924
|
-
expect((published as any).fields).toEqual(['first_name']);
|
|
925
|
-
});
|
|
926
|
-
|
|
927
|
-
it('should return undefined for non-existent item', async () => {
|
|
928
|
-
const result = await manager.getPublished('object', 'nonexistent');
|
|
929
|
-
expect(result).toBeUndefined();
|
|
930
|
-
});
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
describe('integration: edit → publish → edit → revert', () => {
|
|
934
|
-
it('should preserve published version through edit-revert cycle', async () => {
|
|
935
|
-
// Step 1: Initial setup
|
|
936
|
-
await manager.register('object', 'project', {
|
|
937
|
-
name: 'project', label: 'Project', packageId: 'pm',
|
|
938
|
-
metadata: { fields: ['name', 'status'] },
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
// Step 2: Publish v1
|
|
942
|
-
const v1 = await manager.publishPackage('pm', { publishedBy: 'admin' });
|
|
943
|
-
expect(v1.success).toBe(true);
|
|
944
|
-
expect(v1.version).toBe(1);
|
|
945
|
-
|
|
946
|
-
// Step 3: Edit after publish
|
|
947
|
-
const item = await manager.get('object', 'project') as any;
|
|
948
|
-
await manager.register('object', 'project', {
|
|
949
|
-
...item,
|
|
950
|
-
metadata: { fields: ['name', 'status', 'priority'] },
|
|
951
|
-
state: 'draft',
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
// Step 4: End user sees published version
|
|
955
|
-
const endUserView = await manager.getPublished('object', 'project') as any;
|
|
956
|
-
expect(endUserView.fields).toEqual(['name', 'status']);
|
|
957
|
-
|
|
958
|
-
// Step 5: Revert discards draft changes
|
|
959
|
-
await manager.revertPackage('pm');
|
|
960
|
-
const reverted = await manager.get('object', 'project') as any;
|
|
961
|
-
expect(reverted.state).toBe('active');
|
|
962
|
-
expect(reverted.metadata.fields).toEqual(['name', 'status']);
|
|
963
|
-
});
|
|
964
|
-
});
|
|
965
|
-
});
|