@objectstack/metadata 3.3.0 → 3.3.1

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.
Files changed (38) hide show
  1. package/dist/index.cjs +2197 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.js +42 -82
  4. package/dist/index.js.map +1 -1
  5. package/dist/node.cjs +2201 -0
  6. package/dist/node.cjs.map +1 -0
  7. package/dist/node.d.cts +65 -0
  8. package/dist/node.d.ts +65 -0
  9. package/dist/{index.mjs → node.js} +3 -1
  10. package/package.json +18 -13
  11. package/.turbo/turbo-build.log +0 -22
  12. package/CHANGELOG.md +0 -504
  13. package/ROADMAP.md +0 -224
  14. package/src/index.ts +0 -68
  15. package/src/loaders/database-loader.test.ts +0 -559
  16. package/src/loaders/database-loader.ts +0 -352
  17. package/src/loaders/filesystem-loader.ts +0 -420
  18. package/src/loaders/loader-interface.ts +0 -89
  19. package/src/loaders/memory-loader.ts +0 -103
  20. package/src/loaders/remote-loader.ts +0 -140
  21. package/src/metadata-manager.ts +0 -1168
  22. package/src/metadata-service.test.ts +0 -965
  23. package/src/metadata.test.ts +0 -431
  24. package/src/migration/executor.ts +0 -54
  25. package/src/migration/index.ts +0 -3
  26. package/src/node-metadata-manager.ts +0 -126
  27. package/src/node.ts +0 -11
  28. package/src/objects/sys-metadata.object.ts +0 -188
  29. package/src/plugin.ts +0 -102
  30. package/src/serializers/json-serializer.ts +0 -73
  31. package/src/serializers/serializer-interface.ts +0 -65
  32. package/src/serializers/serializers.test.ts +0 -74
  33. package/src/serializers/typescript-serializer.ts +0 -127
  34. package/src/serializers/yaml-serializer.ts +0 -49
  35. package/tsconfig.json +0 -9
  36. package/vitest.config.ts +0 -23
  37. /package/dist/{index.d.mts → index.d.cts} +0 -0
  38. /package/dist/{index.mjs.map → node.js.map} +0 -0
@@ -1,559 +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 { DatabaseLoader, type DatabaseLoaderOptions } from './database-loader';
5
- import type { IDataDriver } from '@objectstack/spec/contracts';
6
- import { MetadataManager } from '../metadata-manager';
7
- import { MemoryLoader } from './memory-loader';
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
- /**
20
- * In-memory IDataDriver mock for testing DatabaseLoader.
21
- * Stores records in a Map keyed by table name → id.
22
- */
23
- function createMockDriver(): IDataDriver {
24
- const tables = new Map<string, Map<string, Record<string, unknown>>>();
25
-
26
- function getTable(name: string): Map<string, Record<string, unknown>> {
27
- if (!tables.has(name)) {
28
- tables.set(name, new Map());
29
- }
30
- return tables.get(name)!;
31
- }
32
-
33
- return {
34
- name: 'mock',
35
- version: '1.0.0',
36
- supports: {
37
- transactions: false,
38
- joins: false,
39
- aggregations: false,
40
- streaming: false,
41
- bulkOperations: true,
42
- nestedObjects: false,
43
- fullTextSearch: false,
44
- geoQueries: false,
45
- changeStreams: false,
46
- },
47
-
48
- connect: vi.fn().mockResolvedValue(undefined),
49
- disconnect: vi.fn().mockResolvedValue(undefined),
50
- checkHealth: vi.fn().mockResolvedValue(true),
51
-
52
- execute: vi.fn().mockResolvedValue(undefined),
53
-
54
- find: vi.fn().mockImplementation((tableName: string, query: any) => {
55
- const table = getTable(tableName);
56
- const where = query?.where ?? {};
57
- const fields = query?.fields as string[] | undefined;
58
-
59
- const results: Record<string, unknown>[] = [];
60
- for (const row of table.values()) {
61
- let match = true;
62
- for (const [key, val] of Object.entries(where)) {
63
- if (row[key] !== val) {
64
- match = false;
65
- break;
66
- }
67
- }
68
- if (match) {
69
- if (fields && fields.length > 0) {
70
- const partial: Record<string, unknown> = {};
71
- for (const f of fields) {
72
- partial[f] = row[f];
73
- }
74
- results.push(partial);
75
- } else {
76
- results.push({ ...row });
77
- }
78
- }
79
- }
80
- return Promise.resolve(results);
81
- }),
82
-
83
- findOne: vi.fn().mockImplementation((tableName: string, query: any) => {
84
- const table = getTable(tableName);
85
- const where = query?.where ?? {};
86
-
87
- for (const row of table.values()) {
88
- let match = true;
89
- for (const [key, val] of Object.entries(where)) {
90
- if (row[key] !== val) {
91
- match = false;
92
- break;
93
- }
94
- }
95
- if (match) return Promise.resolve({ ...row });
96
- }
97
- return Promise.resolve(null);
98
- }),
99
-
100
- findStream: vi.fn(),
101
-
102
- create: vi.fn().mockImplementation((tableName: string, data: Record<string, unknown>) => {
103
- const table = getTable(tableName);
104
- const id = data.id as string;
105
- table.set(id, { ...data });
106
- return Promise.resolve({ ...data });
107
- }),
108
-
109
- update: vi.fn().mockImplementation((tableName: string, id: string, data: Record<string, unknown>) => {
110
- const table = getTable(tableName);
111
- const existing = table.get(id);
112
- if (!existing) throw new Error('Not found');
113
- const updated = { ...existing, ...data };
114
- table.set(id, updated);
115
- return Promise.resolve(updated);
116
- }),
117
-
118
- upsert: vi.fn().mockResolvedValue({}),
119
-
120
- delete: vi.fn().mockImplementation((tableName: string, id: string) => {
121
- const table = getTable(tableName);
122
- const existed = table.has(id);
123
- table.delete(id);
124
- return Promise.resolve(existed);
125
- }),
126
-
127
- count: vi.fn().mockImplementation((tableName: string, query: any) => {
128
- const table = getTable(tableName);
129
- const where = query?.where ?? {};
130
-
131
- let count = 0;
132
- for (const row of table.values()) {
133
- let match = true;
134
- for (const [key, val] of Object.entries(where)) {
135
- if (row[key] !== val) {
136
- match = false;
137
- break;
138
- }
139
- }
140
- if (match) count++;
141
- }
142
- return Promise.resolve(count);
143
- }),
144
-
145
- bulkCreate: vi.fn().mockResolvedValue([]),
146
- bulkUpdate: vi.fn().mockResolvedValue([]),
147
- bulkDelete: vi.fn().mockResolvedValue(undefined),
148
-
149
- beginTransaction: vi.fn().mockResolvedValue({}),
150
- commit: vi.fn().mockResolvedValue(undefined),
151
- rollback: vi.fn().mockResolvedValue(undefined),
152
-
153
- syncSchema: vi.fn().mockResolvedValue(undefined),
154
- dropTable: vi.fn().mockResolvedValue(undefined),
155
- };
156
- }
157
-
158
- // ---------- DatabaseLoader ----------
159
-
160
- describe('DatabaseLoader', () => {
161
- let loader: DatabaseLoader;
162
- let mockDriver: IDataDriver;
163
-
164
- beforeEach(() => {
165
- mockDriver = createMockDriver();
166
- loader = new DatabaseLoader({ driver: mockDriver });
167
- });
168
-
169
- describe('contract', () => {
170
- it('should have correct contract metadata', () => {
171
- expect(loader.contract.name).toBe('database');
172
- expect(loader.contract.protocol).toBe('datasource:');
173
- expect(loader.contract.capabilities.read).toBe(true);
174
- expect(loader.contract.capabilities.write).toBe(true);
175
- expect(loader.contract.capabilities.watch).toBe(false);
176
- expect(loader.contract.capabilities.list).toBe(true);
177
- });
178
- });
179
-
180
- describe('schema bootstrapping', () => {
181
- it('should call syncSchema with SysMetadataObject on first operation', async () => {
182
- await loader.list('object');
183
- expect(mockDriver.syncSchema).toHaveBeenCalledOnce();
184
- expect(mockDriver.syncSchema).toHaveBeenCalledWith(
185
- 'sys_metadata',
186
- expect.objectContaining({
187
- name: 'sys_metadata',
188
- isSystem: true,
189
- fields: expect.objectContaining({
190
- id: expect.objectContaining({ type: 'text' }),
191
- name: expect.objectContaining({ type: 'text' }),
192
- type: expect.objectContaining({ type: 'text' }),
193
- scope: expect.objectContaining({ type: 'select' }),
194
- metadata: expect.objectContaining({ type: 'textarea' }),
195
- }),
196
- })
197
- );
198
- });
199
-
200
- it('should only call syncSchema once (idempotent)', async () => {
201
- await loader.list('object');
202
- await loader.list('view');
203
- await loader.exists('object', 'account');
204
- expect(mockDriver.syncSchema).toHaveBeenCalledOnce();
205
- });
206
-
207
- it('should use custom table name', async () => {
208
- const customLoader = new DatabaseLoader({
209
- driver: mockDriver,
210
- tableName: 'custom_metadata',
211
- });
212
- await customLoader.list('object');
213
- expect(mockDriver.syncSchema).toHaveBeenCalledWith(
214
- 'custom_metadata',
215
- expect.objectContaining({ name: 'custom_metadata' })
216
- );
217
- });
218
- });
219
-
220
- describe('save and load', () => {
221
- it('should save and load a metadata item', async () => {
222
- const data = { name: 'account', label: 'Account', fields: {} };
223
- await loader.save('object', 'account', data);
224
-
225
- const result = await loader.load('object', 'account');
226
- expect(result.data).toEqual(data);
227
- expect(result.source).toBe('database');
228
- expect(result.format).toBe('json');
229
- });
230
-
231
- it('should return null for non-existent item', async () => {
232
- const result = await loader.load('object', 'missing');
233
- expect(result.data).toBeNull();
234
- });
235
-
236
- it('should update existing item on re-save', async () => {
237
- await loader.save('object', 'account', { name: 'account', label: 'V1' });
238
- await loader.save('object', 'account', { name: 'account', label: 'V2' });
239
-
240
- const result = await loader.load('object', 'account');
241
- expect(result.data).toEqual({ name: 'account', label: 'V2' });
242
- });
243
-
244
- it('should return save result with path', async () => {
245
- const result = await loader.save('object', 'account', { name: 'account' });
246
- expect(result.success).toBe(true);
247
- expect(result.path).toBe('datasource://sys_metadata/object/account');
248
- expect(result.size).toBeGreaterThan(0);
249
- expect(result.saveTime).toBeDefined();
250
- });
251
-
252
- it('should increment version on update', async () => {
253
- await loader.save('object', 'account', { name: 'account' });
254
- await loader.save('object', 'account', { name: 'account', label: 'Updated' });
255
-
256
- // The update call should have been made with incremented version
257
- expect(mockDriver.update).toHaveBeenCalledWith(
258
- 'sys_metadata',
259
- expect.any(String),
260
- expect.objectContaining({ version: 2 })
261
- );
262
- });
263
- });
264
-
265
- describe('exists', () => {
266
- it('should return false for non-existent items', async () => {
267
- expect(await loader.exists('object', 'nope')).toBe(false);
268
- });
269
-
270
- it('should return true for existing items', async () => {
271
- await loader.save('object', 'account', { name: 'account' });
272
- expect(await loader.exists('object', 'account')).toBe(true);
273
- });
274
-
275
- it('should differentiate between types', async () => {
276
- await loader.save('object', 'account', { name: 'account' });
277
- expect(await loader.exists('object', 'account')).toBe(true);
278
- expect(await loader.exists('view', 'account')).toBe(false);
279
- });
280
- });
281
-
282
- describe('list', () => {
283
- it('should return empty array for empty type', async () => {
284
- const items = await loader.list('object');
285
- expect(items).toEqual([]);
286
- });
287
-
288
- it('should list all items of a type', async () => {
289
- await loader.save('object', 'account', { name: 'account' });
290
- await loader.save('object', 'contact', { name: 'contact' });
291
- await loader.save('view', 'account_list', { name: 'account_list' });
292
-
293
- const objects = await loader.list('object');
294
- expect(objects).toHaveLength(2);
295
- expect(objects).toContain('account');
296
- expect(objects).toContain('contact');
297
-
298
- const views = await loader.list('view');
299
- expect(views).toHaveLength(1);
300
- expect(views).toContain('account_list');
301
- });
302
- });
303
-
304
- describe('loadMany', () => {
305
- it('should return empty array for unknown type', async () => {
306
- const items = await loader.loadMany('object');
307
- expect(items).toEqual([]);
308
- });
309
-
310
- it('should return all items of a type', async () => {
311
- await loader.save('object', 'account', { name: 'account' });
312
- await loader.save('object', 'contact', { name: 'contact' });
313
-
314
- const items = await loader.loadMany<{ name: string }>('object');
315
- expect(items).toHaveLength(2);
316
- expect(items.map(i => i.name)).toContain('account');
317
- expect(items.map(i => i.name)).toContain('contact');
318
- });
319
-
320
- it('should not include items from other types', async () => {
321
- await loader.save('object', 'account', { name: 'account' });
322
- await loader.save('view', 'account_list', { name: 'account_list' });
323
-
324
- const objects = await loader.loadMany('object');
325
- expect(objects).toHaveLength(1);
326
- });
327
- });
328
-
329
- describe('stat', () => {
330
- it('should return null for missing items', async () => {
331
- const stats = await loader.stat('object', 'missing');
332
- expect(stats).toBeNull();
333
- });
334
-
335
- it('should return stats for existing items', async () => {
336
- await loader.save('object', 'account', { name: 'account' });
337
- const stats = await loader.stat('object', 'account');
338
- expect(stats).not.toBeNull();
339
- expect(stats!.format).toBe('json');
340
- expect(stats!.size).toBeGreaterThan(0);
341
- });
342
- });
343
-
344
- describe('multi-tenant isolation', () => {
345
- it('should filter by tenantId when configured', async () => {
346
- const tenantLoader = new DatabaseLoader({
347
- driver: mockDriver,
348
- tenantId: 'tenant-1',
349
- });
350
-
351
- await tenantLoader.save('object', 'account', { name: 'account' });
352
-
353
- // The create call should include tenant_id
354
- expect(mockDriver.create).toHaveBeenCalledWith(
355
- 'sys_metadata',
356
- expect.objectContaining({ tenant_id: 'tenant-1' })
357
- );
358
-
359
- // The find calls should filter by tenant_id
360
- await tenantLoader.load('object', 'account');
361
- expect(mockDriver.findOne).toHaveBeenCalledWith(
362
- 'sys_metadata',
363
- expect.objectContaining({
364
- where: expect.objectContaining({ tenant_id: 'tenant-1' }),
365
- })
366
- );
367
- });
368
- });
369
-
370
- describe('error handling', () => {
371
- it('should return null data on load failure', async () => {
372
- const failingDriver = createMockDriver();
373
- failingDriver.findOne = vi.fn().mockRejectedValue(new Error('DB error'));
374
- const failLoader = new DatabaseLoader({ driver: failingDriver });
375
-
376
- const result = await failLoader.load('object', 'account');
377
- expect(result.data).toBeNull();
378
- });
379
-
380
- it('should return empty array on loadMany failure', async () => {
381
- const failingDriver = createMockDriver();
382
- failingDriver.find = vi.fn().mockRejectedValue(new Error('DB error'));
383
- const failLoader = new DatabaseLoader({ driver: failingDriver });
384
-
385
- const result = await failLoader.loadMany('object');
386
- expect(result).toEqual([]);
387
- });
388
-
389
- it('should return false on exists failure', async () => {
390
- const failingDriver = createMockDriver();
391
- failingDriver.count = vi.fn().mockRejectedValue(new Error('DB error'));
392
- const failLoader = new DatabaseLoader({ driver: failingDriver });
393
-
394
- expect(await failLoader.exists('object', 'account')).toBe(false);
395
- });
396
-
397
- it('should return null on stat failure', async () => {
398
- const failingDriver = createMockDriver();
399
- failingDriver.findOne = vi.fn().mockRejectedValue(new Error('DB error'));
400
- const failLoader = new DatabaseLoader({ driver: failingDriver });
401
-
402
- expect(await failLoader.stat('object', 'account')).toBeNull();
403
- });
404
-
405
- it('should return empty array on list failure', async () => {
406
- const failingDriver = createMockDriver();
407
- failingDriver.find = vi.fn().mockRejectedValue(new Error('DB error'));
408
- const failLoader = new DatabaseLoader({ driver: failingDriver });
409
-
410
- expect(await failLoader.list('object')).toEqual([]);
411
- });
412
-
413
- it('should throw descriptive error on save failure', async () => {
414
- const failingDriver = createMockDriver();
415
- failingDriver.findOne = vi.fn().mockResolvedValue(null);
416
- failingDriver.create = vi.fn().mockRejectedValue(new Error('Insert failed'));
417
- const failLoader = new DatabaseLoader({ driver: failingDriver });
418
-
419
- await expect(
420
- failLoader.save('object', 'account', { name: 'account' })
421
- ).rejects.toThrow('DatabaseLoader save failed for object/account: Insert failed');
422
- });
423
- });
424
- });
425
-
426
- // ---------- MetadataManager + DatabaseLoader Integration ----------
427
-
428
- describe('MetadataManager with DatabaseLoader', () => {
429
- let manager: MetadataManager;
430
- let dbLoader: DatabaseLoader;
431
- let memoryLoader: MemoryLoader;
432
- let mockDriver: IDataDriver;
433
-
434
- beforeEach(() => {
435
- mockDriver = createMockDriver();
436
- dbLoader = new DatabaseLoader({ driver: mockDriver });
437
- memoryLoader = new MemoryLoader();
438
- manager = new MetadataManager({
439
- formats: ['json'],
440
- loaders: [memoryLoader, dbLoader],
441
- });
442
- });
443
-
444
- it('should save and load via DatabaseLoader', async () => {
445
- await manager.save('object', 'account', { name: 'account' }, { loader: 'database' } as any);
446
- const result = await manager.load('object', 'account');
447
- expect(result).toEqual({ name: 'account' });
448
- });
449
-
450
- it('should list items from both loaders', async () => {
451
- await memoryLoader.save('object', 'account', { name: 'account' });
452
- await dbLoader.save('object', 'contact', { name: 'contact' });
453
-
454
- const names = await manager.listNames('object');
455
- expect(names).toContain('account');
456
- expect(names).toContain('contact');
457
- });
458
-
459
- it('should deduplicate items across memory and database loaders', async () => {
460
- await memoryLoader.save('object', 'account', { name: 'account', label: 'Memory' });
461
- await dbLoader.save('object', 'account', { name: 'account', label: 'Database' });
462
-
463
- const items = await manager.loadMany<{ name: string; label: string }>('object');
464
- const accounts = items.filter(i => i.name === 'account');
465
- expect(accounts).toHaveLength(1);
466
- // First loader (memory) wins
467
- expect(accounts[0].label).toBe('Memory');
468
- });
469
-
470
- it('should check existence across both loaders', async () => {
471
- await dbLoader.save('object', 'contact', { name: 'contact' });
472
- expect(await manager.exists('object', 'contact')).toBe(true);
473
- });
474
-
475
- it('should use DatabaseLoader for overlay persistence', async () => {
476
- // Register base metadata
477
- await manager.register('object', 'account', { name: 'account', label: 'Account' });
478
-
479
- // Save an overlay to the database
480
- await dbLoader.save('overlay', 'account_platform', {
481
- name: 'account_platform',
482
- baseType: 'object',
483
- baseName: 'account',
484
- scope: 'platform',
485
- patch: { label: 'Custom Account' },
486
- active: true,
487
- });
488
-
489
- // Verify the overlay is persisted in database
490
- const overlayResult = await dbLoader.load('overlay', 'account_platform');
491
- expect(overlayResult.data).toBeDefined();
492
- expect((overlayResult.data as any).patch.label).toBe('Custom Account');
493
- });
494
- });
495
-
496
- // ---------- MetadataManager Auto-Configuration ----------
497
-
498
- describe('MetadataManager auto-configuration', () => {
499
- it('should auto-register DatabaseLoader when datasource and driver are provided', async () => {
500
- const mockDriver = createMockDriver();
501
- const manager = new MetadataManager({
502
- formats: ['json'],
503
- datasource: 'default',
504
- driver: mockDriver,
505
- });
506
-
507
- // The database loader should have been registered automatically
508
- // Verify by saving and loading data through the manager
509
- await manager.save('object', 'account', { name: 'account', label: 'Account' });
510
- const result = await manager.load('object', 'account');
511
- expect(result).toEqual({ name: 'account', label: 'Account' });
512
- });
513
-
514
- it('should NOT auto-register DatabaseLoader when only datasource is set (no driver)', async () => {
515
- const manager = new MetadataManager({
516
- formats: ['json'],
517
- datasource: 'default',
518
- // No driver provided
519
- });
520
-
521
- // No loaders should be registered, so save should fail
522
- await expect(
523
- manager.save('object', 'account', { name: 'account' })
524
- ).rejects.toThrow('No loader available');
525
- });
526
-
527
- it('should use custom tableName from config', async () => {
528
- const mockDriver = createMockDriver();
529
- const manager = new MetadataManager({
530
- formats: ['json'],
531
- datasource: 'default',
532
- tableName: 'custom_metadata',
533
- driver: mockDriver,
534
- });
535
-
536
- await manager.save('object', 'account', { name: 'account' });
537
- // syncSchema should be called with custom table name
538
- expect(mockDriver.syncSchema).toHaveBeenCalledWith(
539
- 'custom_metadata',
540
- expect.objectContaining({ name: 'custom_metadata' })
541
- );
542
- });
543
-
544
- it('should support deferred database setup via setDatabaseDriver', async () => {
545
- const mockDriver = createMockDriver();
546
- const manager = new MetadataManager({
547
- formats: ['json'],
548
- datasource: 'default',
549
- });
550
-
551
- // No database loader yet — use deferred setup
552
- manager.setDatabaseDriver(mockDriver);
553
-
554
- // Now save and load should work via the database loader
555
- await manager.save('object', 'account', { name: 'account', label: 'Account' });
556
- const result = await manager.load('object', 'account');
557
- expect(result).toEqual({ name: 'account', label: 'Account' });
558
- });
559
- });