@objectstack/objectql 4.0.3 → 4.0.5

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.
@@ -1,995 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, beforeEach } from 'vitest';
4
- import { ObjectKernel } from '@objectstack/core';
5
- import { ObjectQLPlugin } from '../src/plugin';
6
- import { SchemaRegistry } from '../src/registry';
7
- import { ObjectSchema } from '@objectstack/spec/data';
8
-
9
- describe('ObjectQLPlugin - Metadata Service Integration', () => {
10
- let kernel: ObjectKernel;
11
-
12
- beforeEach(() => {
13
- SchemaRegistry.reset();
14
- kernel = new ObjectKernel({ logLevel: 'silent' });
15
- });
16
-
17
- describe('Simple Mode (ObjectQL-only)', () => {
18
- it('should register objectql, data, and protocol services', async () => {
19
- // Arrange
20
- const plugin = new ObjectQLPlugin();
21
- await kernel.use(plugin);
22
-
23
- // Act
24
- await kernel.bootstrap();
25
-
26
- // Assert — ObjectQL no longer registers metadata (kernel provides fallback)
27
- const objectql = kernel.getService('objectql');
28
- expect(objectql).toBeDefined();
29
- expect(kernel.getService('data')).toBeDefined();
30
- expect(kernel.getService('protocol')).toBeDefined();
31
- // metadata is provided by kernel's core fallback, not ObjectQL
32
- const metadataService = kernel.getService('metadata');
33
- expect(metadataService).toBeDefined();
34
- expect((metadataService as any)._fallback).toBe(true);
35
- });
36
-
37
- it('should serve in-memory metadata definitions', async () => {
38
- // Arrange
39
- const plugin = new ObjectQLPlugin();
40
- await kernel.use(plugin);
41
- await kernel.bootstrap();
42
-
43
- const objectql = kernel.getService('objectql') as any;
44
- const testObject: ObjectSchema = {
45
- name: 'test_object',
46
- label: 'Test Object',
47
- fields: {
48
- name: {
49
- name: 'name',
50
- label: 'Name',
51
- type: 'text'
52
- }
53
- }
54
- };
55
-
56
- // Act - Register object programmatically via the SchemaRegistry API
57
- objectql.registry.registerObject(testObject, 'test', 'test');
58
-
59
- // Assert - Should be retrievable via registry (getAllObjects returns ServiceObject[])
60
- const objects = objectql.registry.getAllObjects();
61
- const fqns = objects.map((o: any) => o.name);
62
- expect(fqns).toContain('test__test_object');
63
- });
64
- });
65
-
66
- describe('Service Registration', () => {
67
- it('should register manifest service', async () => {
68
- // Arrange
69
- const plugin = new ObjectQLPlugin();
70
- await kernel.use(plugin);
71
-
72
- // Act
73
- await kernel.bootstrap();
74
-
75
- // Assert
76
- expect(kernel.getService('objectql')).toBeDefined();
77
- expect(kernel.getService('data')).toBeDefined();
78
- expect(kernel.getService('protocol')).toBeDefined();
79
- expect(kernel.getService('manifest')).toBeDefined();
80
- });
81
-
82
- it('should respect existing metadata service', async () => {
83
- // Arrange - Register a mock metadata service first
84
- const mockMetadataService = {
85
- load: async () => null,
86
- loadMany: async () => [],
87
- save: async () => ({ success: true }),
88
- exists: async () => false,
89
- list: async () => []
90
- };
91
-
92
- await kernel.use({
93
- name: 'mock-metadata',
94
- type: 'test',
95
- version: '1.0.0',
96
- init: async (ctx) => {
97
- ctx.registerService('metadata', mockMetadataService);
98
- }
99
- });
100
-
101
- const plugin = new ObjectQLPlugin();
102
- await kernel.use(plugin);
103
-
104
- // Act
105
- await kernel.bootstrap();
106
-
107
- // Assert - metadata service should be the mock, not ObjectQL
108
- const metadataService = kernel.getService('metadata');
109
- expect(metadataService).toBe(mockMetadataService);
110
-
111
- const objectql = kernel.getService('objectql');
112
- expect(metadataService).not.toBe(objectql);
113
- });
114
- });
115
-
116
- describe('Driver and App Discovery', () => {
117
- it('should discover and register drivers from kernel services', async () => {
118
- // Arrange
119
- const mockDriver = {
120
- name: 'mock-driver',
121
- connect: async () => {},
122
- disconnect: async () => {},
123
- query: async () => ({ rows: [] }),
124
- insert: async () => ({ id: '1' }),
125
- update: async () => ({ count: 1 }),
126
- delete: async () => ({ count: 1 })
127
- };
128
-
129
- await kernel.use({
130
- name: 'mock-driver-plugin',
131
- type: 'driver',
132
- version: '1.0.0',
133
- init: async (ctx) => {
134
- ctx.registerService('driver.mock', mockDriver);
135
- }
136
- });
137
-
138
- const plugin = new ObjectQLPlugin();
139
- await kernel.use(plugin);
140
-
141
- // Act
142
- await kernel.bootstrap();
143
-
144
- // Assert
145
- const objectql = kernel.getService('objectql') as any;
146
- expect(objectql.drivers?.has('mock-driver')).toBe(true);
147
- });
148
-
149
- it('should register apps via manifest service', async () => {
150
- // Arrange
151
- const plugin = new ObjectQLPlugin();
152
- await kernel.use(plugin);
153
-
154
- // Plugin that uses the manifest service directly
155
- await kernel.use({
156
- name: 'mock-app-plugin',
157
- type: 'app',
158
- version: '1.0.0',
159
- dependencies: ['com.objectstack.engine.objectql'],
160
- init: async (ctx) => {
161
- ctx.getService<{ register(m: any): void }>('manifest').register({
162
- id: 'test-app',
163
- name: 'test_app',
164
- version: '1.0.0',
165
- type: 'app',
166
- apps: [{ name: 'Test App' }],
167
- });
168
- }
169
- });
170
-
171
- // Act
172
- await kernel.bootstrap();
173
-
174
- // Assert
175
- const objectql = kernel.getService('objectql') as any;
176
- expect(objectql.registry).toBeDefined();
177
- const apps = objectql.registry.getAllApps();
178
- expect(apps.some((a: any) => a.name === 'Test App')).toBe(true);
179
- });
180
-
181
- it('should register manifests from start() phase via manifest service', async () => {
182
- // Arrange — simulates SetupPlugin's pattern (registers in start, not init)
183
- const plugin = new ObjectQLPlugin();
184
- await kernel.use(plugin);
185
-
186
- await kernel.use({
187
- name: 'late-registerer',
188
- type: 'standard',
189
- version: '1.0.0',
190
- dependencies: ['com.objectstack.engine.objectql'],
191
- init: async () => {},
192
- start: async (ctx) => {
193
- ctx.getService<{ register(m: any): void }>('manifest').register({
194
- id: 'late-app',
195
- name: 'late_app',
196
- version: '1.0.0',
197
- type: 'plugin',
198
- apps: [{ name: 'Late App' }],
199
- });
200
- }
201
- });
202
-
203
- // Act
204
- await kernel.bootstrap();
205
-
206
- // Assert
207
- const objectql = kernel.getService('objectql') as any;
208
- const apps = objectql.registry.getAllApps();
209
- expect(apps.some((a: any) => a.name === 'Late App')).toBe(true);
210
- });
211
-
212
- it('should still discover apps registered via legacy app.* convention', async () => {
213
- // Arrange — legacy pattern for backward compatibility
214
- const mockApp = {
215
- manifest: {
216
- id: 'test-app',
217
- name: 'test_app',
218
- version: '1.0.0',
219
- type: 'app'
220
- }
221
- };
222
-
223
- await kernel.use({
224
- name: 'mock-app-plugin',
225
- type: 'app',
226
- version: '1.0.0',
227
- init: async (ctx) => {
228
- ctx.registerService('app.test', mockApp.manifest);
229
- }
230
- });
231
-
232
- const plugin = new ObjectQLPlugin();
233
- await kernel.use(plugin);
234
-
235
- // Act
236
- await kernel.bootstrap();
237
-
238
- // Assert — legacy pattern still works
239
- const objectql = kernel.getService('objectql') as any;
240
- expect(objectql.registry).toBeDefined();
241
- });
242
- });
243
-
244
- describe('Metadata Sync from External Service', () => {
245
- it('should load metadata from external service into ObjectQL registry', async () => {
246
- // Arrange - Mock external metadata service with test data
247
- const testObject: ObjectSchema = {
248
- name: 'external_object',
249
- label: 'External Object',
250
- fields: {
251
- title: {
252
- name: 'title',
253
- label: 'Title',
254
- type: 'text'
255
- }
256
- }
257
- };
258
-
259
- const mockMetadataService = {
260
- load: async (type: string, name: string) => {
261
- if (type === 'object' && name === 'external_object') {
262
- return testObject;
263
- }
264
- return null;
265
- },
266
- loadMany: async (type: string) => {
267
- if (type === 'object') {
268
- return [testObject];
269
- }
270
- return [];
271
- },
272
- save: async () => ({ success: true, path: '/test' }),
273
- exists: async () => false,
274
- list: async () => []
275
- };
276
-
277
- // Register mock metadata service BEFORE ObjectQL
278
- await kernel.use({
279
- name: 'mock-metadata',
280
- type: 'metadata',
281
- version: '1.0.0',
282
- init: async (ctx) => {
283
- ctx.registerService('metadata', mockMetadataService);
284
- }
285
- });
286
-
287
- const plugin = new ObjectQLPlugin();
288
- await kernel.use(plugin);
289
-
290
- // Act
291
- await kernel.bootstrap();
292
-
293
- // Assert - Metadata should be synced
294
- const metadataService = kernel.getService('metadata');
295
- expect(metadataService).toBe(mockMetadataService);
296
-
297
- const objectql = kernel.getService('objectql') as any;
298
- expect(objectql.registry).toBeDefined();
299
-
300
- // Note: The actual sync happens in start phase
301
- // We can verify by checking if ObjectQL detected external service
302
- });
303
- });
304
-
305
- describe('Schema Sync on Start', () => {
306
- it('should call syncSchema for each registered object after init', async () => {
307
- // Arrange - driver that tracks syncSchema calls
308
- const synced: Array<{ object: string; schema: any }> = [];
309
- const mockDriver = {
310
- name: 'sync-driver',
311
- version: '1.0.0',
312
- connect: async () => {},
313
- disconnect: async () => {},
314
- find: async () => [],
315
- findOne: async () => null,
316
- create: async (_o: string, d: any) => d,
317
- update: async (_o: string, _i: any, d: any) => d,
318
- delete: async () => true,
319
- syncSchema: async (object: string, schema: any) => {
320
- synced.push({ object, schema });
321
- },
322
- };
323
-
324
- // Plugin that registers objects and a driver
325
- await kernel.use({
326
- name: 'mock-driver-plugin',
327
- type: 'driver',
328
- version: '1.0.0',
329
- init: async (ctx) => {
330
- ctx.registerService('driver.sync', mockDriver);
331
- },
332
- });
333
-
334
- const appManifest = {
335
- id: 'com.test.auth',
336
- name: 'auth',
337
- namespace: 'sys',
338
- version: '1.0.0',
339
- objects: [
340
- {
341
- name: 'user',
342
- label: 'User',
343
- fields: {
344
- name: { name: 'name', label: 'Name', type: 'text' },
345
- },
346
- },
347
- {
348
- name: 'role',
349
- label: 'Role',
350
- fields: {
351
- title: { name: 'title', label: 'Title', type: 'text' },
352
- },
353
- },
354
- ],
355
- };
356
-
357
- await kernel.use({
358
- name: 'mock-app-plugin',
359
- type: 'app',
360
- version: '1.0.0',
361
- init: async (ctx) => {
362
- ctx.registerService('app.auth', appManifest);
363
- },
364
- });
365
-
366
- const plugin = new ObjectQLPlugin();
367
- await kernel.use(plugin);
368
-
369
- // Act
370
- await kernel.bootstrap();
371
-
372
- // Assert - syncSchema should have been called for each object
373
- const syncedObjects = synced.map((s) => s.object).sort();
374
- expect(syncedObjects).toContain('sys__user');
375
- expect(syncedObjects).toContain('sys__role');
376
- expect(synced.length).toBeGreaterThanOrEqual(2);
377
- });
378
-
379
- it('should tolerate drivers without syncSchema', async () => {
380
- // Arrange - driver without syncSchema
381
- const mockDriver = {
382
- name: 'no-sync-driver',
383
- version: '1.0.0',
384
- connect: async () => {},
385
- disconnect: async () => {},
386
- find: async () => [],
387
- findOne: async () => null,
388
- create: async (_o: string, d: any) => d,
389
- update: async (_o: string, _i: any, d: any) => d,
390
- delete: async () => true,
391
- // No syncSchema method
392
- };
393
-
394
- await kernel.use({
395
- name: 'mock-driver-plugin',
396
- type: 'driver',
397
- version: '1.0.0',
398
- init: async (ctx) => {
399
- ctx.registerService('driver.nosync', mockDriver);
400
- },
401
- });
402
-
403
- const appManifest = {
404
- id: 'com.test.simple',
405
- name: 'simple',
406
- namespace: 'test',
407
- version: '1.0.0',
408
- objects: [
409
- {
410
- name: 'item',
411
- label: 'Item',
412
- fields: {
413
- title: { name: 'title', label: 'Title', type: 'text' },
414
- },
415
- },
416
- ],
417
- };
418
-
419
- await kernel.use({
420
- name: 'mock-app-plugin',
421
- type: 'app',
422
- version: '1.0.0',
423
- init: async (ctx) => {
424
- ctx.registerService('app.simple', appManifest);
425
- },
426
- });
427
-
428
- const plugin = new ObjectQLPlugin();
429
- await kernel.use(plugin);
430
-
431
- // Act & Assert - should not throw
432
- await expect(kernel.bootstrap()).resolves.not.toThrow();
433
- });
434
-
435
- it('should tolerate syncSchema failures per object without aborting', async () => {
436
- // Arrange - driver where syncSchema fails for one object
437
- const synced: string[] = [];
438
- const mockDriver = {
439
- name: 'fail-driver',
440
- version: '1.0.0',
441
- connect: async () => {},
442
- disconnect: async () => {},
443
- find: async () => [],
444
- findOne: async () => null,
445
- create: async (_o: string, d: any) => d,
446
- update: async (_o: string, _i: any, d: any) => d,
447
- delete: async () => true,
448
- syncSchema: async (object: string) => {
449
- if (object.includes('bad')) {
450
- throw new Error('sync failed for bad object');
451
- }
452
- synced.push(object);
453
- },
454
- };
455
-
456
- await kernel.use({
457
- name: 'mock-driver-plugin',
458
- type: 'driver',
459
- version: '1.0.0',
460
- init: async (ctx) => {
461
- ctx.registerService('driver.fail', mockDriver);
462
- },
463
- });
464
-
465
- const appManifest = {
466
- id: 'com.test.mixed',
467
- name: 'mixed',
468
- namespace: 'mix',
469
- version: '1.0.0',
470
- objects: [
471
- {
472
- name: 'good',
473
- label: 'Good',
474
- fields: { a: { name: 'a', label: 'A', type: 'text' } },
475
- },
476
- {
477
- name: 'bad',
478
- label: 'Bad',
479
- fields: { b: { name: 'b', label: 'B', type: 'text' } },
480
- },
481
- ],
482
- };
483
-
484
- await kernel.use({
485
- name: 'mock-app-plugin',
486
- type: 'app',
487
- version: '1.0.0',
488
- init: async (ctx) => {
489
- ctx.registerService('app.mixed', appManifest);
490
- },
491
- });
492
-
493
- const plugin = new ObjectQLPlugin();
494
- await kernel.use(plugin);
495
-
496
- // Act - should not throw despite one object failing
497
- await expect(kernel.bootstrap()).resolves.not.toThrow();
498
-
499
- // Assert - the good object should still have been synced
500
- expect(synced).toContain('mix__good');
501
- });
502
-
503
- it('should work without any registered objects', async () => {
504
- // Arrange - no objects, just a driver
505
- const mockDriver = {
506
- name: 'empty-driver',
507
- version: '1.0.0',
508
- connect: async () => {},
509
- disconnect: async () => {},
510
- find: async () => [],
511
- findOne: async () => null,
512
- create: async (_o: string, d: any) => d,
513
- update: async (_o: string, _i: any, d: any) => d,
514
- delete: async () => true,
515
- syncSchema: async () => {},
516
- };
517
-
518
- await kernel.use({
519
- name: 'mock-driver-plugin',
520
- type: 'driver',
521
- version: '1.0.0',
522
- init: async (ctx) => {
523
- ctx.registerService('driver.empty', mockDriver);
524
- },
525
- });
526
-
527
- const plugin = new ObjectQLPlugin();
528
- await kernel.use(plugin);
529
-
530
- // Act & Assert - should not throw
531
- await expect(kernel.bootstrap()).resolves.not.toThrow();
532
- });
533
-
534
- it('should use tableName for syncSchema when objects have auto-derived tableName', async () => {
535
- // Arrange - driver that tracks syncSchema calls
536
- const synced: Array<{ object: string; schema: any }> = [];
537
- const mockDriver = {
538
- name: 'table-name-driver',
539
- version: '1.0.0',
540
- connect: async () => {},
541
- disconnect: async () => {},
542
- find: async () => [],
543
- findOne: async () => null,
544
- create: async (_o: string, d: any) => d,
545
- update: async (_o: string, _i: any, d: any) => d,
546
- delete: async () => true,
547
- syncSchema: async (object: string, schema: any) => {
548
- synced.push({ object, schema });
549
- },
550
- };
551
-
552
- await kernel.use({
553
- name: 'mock-driver-plugin',
554
- type: 'driver',
555
- version: '1.0.0',
556
- init: async (ctx) => {
557
- ctx.registerService('driver.table-name', mockDriver);
558
- },
559
- });
560
-
561
- // Objects with tableName (simulating ObjectSchema.create() output)
562
- const appManifest = {
563
- id: 'com.test.system',
564
- name: 'system',
565
- namespace: 'sys',
566
- version: '1.0.0',
567
- objects: [
568
- {
569
- name: 'user',
570
- label: 'User',
571
- namespace: 'sys',
572
- tableName: 'sys_user',
573
- fields: {
574
- email: { name: 'email', label: 'Email', type: 'text' },
575
- },
576
- },
577
- {
578
- name: 'session',
579
- label: 'Session',
580
- namespace: 'sys',
581
- tableName: 'sys_session',
582
- fields: {
583
- token: { name: 'token', label: 'Token', type: 'text' },
584
- },
585
- },
586
- ],
587
- };
588
-
589
- await kernel.use({
590
- name: 'mock-app-plugin',
591
- type: 'app',
592
- version: '1.0.0',
593
- init: async (ctx) => {
594
- ctx.registerService('app.system', appManifest);
595
- },
596
- });
597
-
598
- const plugin = new ObjectQLPlugin();
599
- await kernel.use(plugin);
600
-
601
- // Act
602
- await kernel.bootstrap();
603
-
604
- // Assert - syncSchema should use tableName (single underscore) not FQN (double underscore)
605
- const syncedNames = synced.map((s) => s.object).sort();
606
- expect(syncedNames).toContain('sys_user');
607
- expect(syncedNames).toContain('sys_session');
608
- // Should NOT contain double-underscore FQN
609
- expect(syncedNames).not.toContain('sys__user');
610
- expect(syncedNames).not.toContain('sys__session');
611
- });
612
-
613
- it('should use syncSchemasBatch when driver supports batchSchemaSync', async () => {
614
- // Arrange - driver that supports batch schema sync
615
- const batchCalls: Array<{ object: string; schema: any }[]> = [];
616
- const singleCalls: Array<{ object: string; schema: any }> = [];
617
- const mockDriver = {
618
- name: 'batch-driver',
619
- version: '1.0.0',
620
- supports: { batchSchemaSync: true },
621
- connect: async () => {},
622
- disconnect: async () => {},
623
- find: async () => [],
624
- findOne: async () => null,
625
- create: async (_o: string, d: any) => d,
626
- update: async (_o: string, _i: any, d: any) => d,
627
- delete: async () => true,
628
- syncSchema: async (object: string, schema: any) => {
629
- singleCalls.push({ object, schema });
630
- },
631
- syncSchemasBatch: async (schemas: Array<{ object: string; schema: any }>) => {
632
- batchCalls.push(schemas);
633
- },
634
- };
635
-
636
- await kernel.use({
637
- name: 'mock-batch-driver-plugin',
638
- type: 'driver',
639
- version: '1.0.0',
640
- init: async (ctx) => {
641
- ctx.registerService('driver.batch', mockDriver);
642
- },
643
- });
644
-
645
- const appManifest = {
646
- id: 'com.test.batchapp',
647
- name: 'batchapp',
648
- namespace: 'bat',
649
- version: '1.0.0',
650
- objects: [
651
- {
652
- name: 'alpha',
653
- label: 'Alpha',
654
- fields: { a: { name: 'a', label: 'A', type: 'text' } },
655
- },
656
- {
657
- name: 'beta',
658
- label: 'Beta',
659
- fields: { b: { name: 'b', label: 'B', type: 'text' } },
660
- },
661
- {
662
- name: 'gamma',
663
- label: 'Gamma',
664
- fields: { c: { name: 'c', label: 'C', type: 'text' } },
665
- },
666
- ],
667
- };
668
-
669
- await kernel.use({
670
- name: 'mock-batch-app-plugin',
671
- type: 'app',
672
- version: '1.0.0',
673
- init: async (ctx) => {
674
- ctx.registerService('app.batchapp', appManifest);
675
- },
676
- });
677
-
678
- const plugin = new ObjectQLPlugin();
679
- await kernel.use(plugin);
680
-
681
- // Act
682
- await kernel.bootstrap();
683
-
684
- // Assert - syncSchemasBatch should have been called once with all objects
685
- expect(batchCalls.length).toBe(1);
686
- const batchedObjects = batchCalls[0].map((s) => s.object).sort();
687
- expect(batchedObjects).toContain('bat__alpha');
688
- expect(batchedObjects).toContain('bat__beta');
689
- expect(batchedObjects).toContain('bat__gamma');
690
- // syncSchema should NOT have been called individually
691
- expect(singleCalls.length).toBe(0);
692
- });
693
-
694
- it('should fall back to sequential syncSchema when batch fails', async () => {
695
- // Arrange - driver where batch fails
696
- const singleCalls: Array<{ object: string; schema: any }> = [];
697
- const mockDriver = {
698
- name: 'fallback-driver',
699
- version: '1.0.0',
700
- supports: { batchSchemaSync: true },
701
- connect: async () => {},
702
- disconnect: async () => {},
703
- find: async () => [],
704
- findOne: async () => null,
705
- create: async (_o: string, d: any) => d,
706
- update: async (_o: string, _i: any, d: any) => d,
707
- delete: async () => true,
708
- syncSchema: async (object: string, schema: any) => {
709
- singleCalls.push({ object, schema });
710
- },
711
- syncSchemasBatch: async () => {
712
- throw new Error('batch not supported at runtime');
713
- },
714
- };
715
-
716
- await kernel.use({
717
- name: 'mock-fallback-driver-plugin',
718
- type: 'driver',
719
- version: '1.0.0',
720
- init: async (ctx) => {
721
- ctx.registerService('driver.fallback', mockDriver);
722
- },
723
- });
724
-
725
- const appManifest = {
726
- id: 'com.test.fallback',
727
- name: 'fallback',
728
- namespace: 'fb',
729
- version: '1.0.0',
730
- objects: [
731
- {
732
- name: 'one',
733
- label: 'One',
734
- fields: { x: { name: 'x', label: 'X', type: 'text' } },
735
- },
736
- {
737
- name: 'two',
738
- label: 'Two',
739
- fields: { y: { name: 'y', label: 'Y', type: 'text' } },
740
- },
741
- ],
742
- };
743
-
744
- await kernel.use({
745
- name: 'mock-fallback-app-plugin',
746
- type: 'app',
747
- version: '1.0.0',
748
- init: async (ctx) => {
749
- ctx.registerService('app.fallback', appManifest);
750
- },
751
- });
752
-
753
- const plugin = new ObjectQLPlugin();
754
- await kernel.use(plugin);
755
-
756
- // Act - should not throw
757
- await expect(kernel.bootstrap()).resolves.not.toThrow();
758
-
759
- // Assert - sequential fallback should have been used
760
- const syncedObjects = singleCalls.map((s) => s.object).sort();
761
- expect(syncedObjects).toContain('fb__one');
762
- expect(syncedObjects).toContain('fb__two');
763
- });
764
-
765
- it('should not use batch when driver does not support batchSchemaSync', async () => {
766
- // Arrange - driver without batch support (but with syncSchema)
767
- const singleCalls: string[] = [];
768
- const mockDriver = {
769
- name: 'nobatch-driver',
770
- version: '1.0.0',
771
- connect: async () => {},
772
- disconnect: async () => {},
773
- find: async () => [],
774
- findOne: async () => null,
775
- create: async (_o: string, d: any) => d,
776
- update: async (_o: string, _i: any, d: any) => d,
777
- delete: async () => true,
778
- syncSchema: async (object: string) => {
779
- singleCalls.push(object);
780
- },
781
- };
782
-
783
- await kernel.use({
784
- name: 'mock-nobatch-driver-plugin',
785
- type: 'driver',
786
- version: '1.0.0',
787
- init: async (ctx) => {
788
- ctx.registerService('driver.nobatch', mockDriver);
789
- },
790
- });
791
-
792
- const appManifest = {
793
- id: 'com.test.nobatch',
794
- name: 'nobatch',
795
- namespace: 'nb',
796
- version: '1.0.0',
797
- objects: [
798
- {
799
- name: 'item',
800
- label: 'Item',
801
- fields: { z: { name: 'z', label: 'Z', type: 'text' } },
802
- },
803
- ],
804
- };
805
-
806
- await kernel.use({
807
- name: 'mock-nobatch-app-plugin',
808
- type: 'app',
809
- version: '1.0.0',
810
- init: async (ctx) => {
811
- ctx.registerService('app.nobatch', appManifest);
812
- },
813
- });
814
-
815
- const plugin = new ObjectQLPlugin();
816
- await kernel.use(plugin);
817
-
818
- // Act
819
- await kernel.bootstrap();
820
-
821
- // Assert - sequential syncSchema should have been used
822
- expect(singleCalls).toContain('nb__item');
823
- });
824
- });
825
-
826
- describe('Cold-Start Metadata Restoration', () => {
827
- it('should restore metadata from sys_metadata via protocol.loadMetaFromDb on start', async () => {
828
- // Arrange — a driver whose find() returns persisted metadata records
829
- const findCalls: Array<{ object: string; query: any }> = [];
830
- const mockDriver = {
831
- name: 'restore-driver',
832
- version: '1.0.0',
833
- connect: async () => {},
834
- disconnect: async () => {},
835
- find: async (object: string, query: any) => {
836
- findCalls.push({ object, query });
837
- if (object === 'sys_metadata') {
838
- return [
839
- {
840
- id: '1',
841
- type: 'apps',
842
- name: 'custom_crm',
843
- state: 'active',
844
- metadata: JSON.stringify({ name: 'custom_crm', label: 'Custom CRM' }),
845
- },
846
- {
847
- id: '2',
848
- type: 'object',
849
- name: 'invoice',
850
- state: 'active',
851
- metadata: JSON.stringify({
852
- name: 'invoice',
853
- label: 'Invoice',
854
- fields: { amount: { name: 'amount', label: 'Amount', type: 'number' } },
855
- }),
856
- packageId: 'user_pkg',
857
- },
858
- ];
859
- }
860
- return [];
861
- },
862
- findOne: async () => null,
863
- create: async (_o: string, d: any) => d,
864
- update: async (_o: string, _i: any, d: any) => d,
865
- delete: async () => true,
866
- syncSchema: async () => {},
867
- };
868
-
869
- await kernel.use({
870
- name: 'mock-restore-driver',
871
- type: 'driver',
872
- version: '1.0.0',
873
- init: async (ctx) => {
874
- ctx.registerService('driver.restore', mockDriver);
875
- },
876
- });
877
-
878
- const plugin = new ObjectQLPlugin();
879
- await kernel.use(plugin);
880
-
881
- // Act
882
- await kernel.bootstrap();
883
-
884
- // Assert — sys_metadata should have been queried
885
- const metaQuery = findCalls.find((c) => c.object === 'sys_metadata');
886
- expect(metaQuery).toBeDefined();
887
- expect(metaQuery!.query.where).toEqual({ state: 'active' });
888
-
889
- // Assert — items should be restored into the registry
890
- const registry = (kernel.getService('objectql') as any).registry;
891
- expect(registry.getAllApps()).toContainEqual({
892
- name: 'custom_crm',
893
- label: 'Custom CRM',
894
- });
895
- });
896
-
897
- it('should not throw when protocol.loadMetaFromDb fails (graceful degradation)', async () => {
898
- // Arrange — driver that throws on find('sys_metadata')
899
- const mockDriver = {
900
- name: 'failing-db-driver',
901
- version: '1.0.0',
902
- connect: async () => {},
903
- disconnect: async () => {},
904
- find: async (object: string) => {
905
- if (object === 'sys_metadata') {
906
- throw new Error('SQLITE_ERROR: no such table: sys_metadata');
907
- }
908
- return [];
909
- },
910
- findOne: async () => null,
911
- create: async (_o: string, d: any) => d,
912
- update: async (_o: string, _i: any, d: any) => d,
913
- delete: async () => true,
914
- syncSchema: async () => {},
915
- };
916
-
917
- await kernel.use({
918
- name: 'mock-fail-driver',
919
- type: 'driver',
920
- version: '1.0.0',
921
- init: async (ctx) => {
922
- ctx.registerService('driver.faildb', mockDriver);
923
- },
924
- });
925
-
926
- const plugin = new ObjectQLPlugin();
927
- await kernel.use(plugin);
928
-
929
- // Act & Assert — should not throw
930
- await expect(kernel.bootstrap()).resolves.not.toThrow();
931
- });
932
-
933
- it('should restore metadata before syncRegisteredSchemas so restored objects get table sync', async () => {
934
- // Arrange — track the order of operations
935
- const operations: string[] = [];
936
- const mockDriver = {
937
- name: 'order-driver',
938
- version: '1.0.0',
939
- connect: async () => {},
940
- disconnect: async () => {},
941
- find: async (object: string) => {
942
- if (object === 'sys_metadata') {
943
- operations.push('loadMetaFromDb');
944
- return [
945
- {
946
- id: '1',
947
- type: 'object',
948
- name: 'restored_obj',
949
- state: 'active',
950
- metadata: JSON.stringify({
951
- name: 'restored_obj',
952
- label: 'Restored Object',
953
- fields: { title: { name: 'title', label: 'Title', type: 'text' } },
954
- }),
955
- packageId: 'user_pkg',
956
- },
957
- ];
958
- }
959
- return [];
960
- },
961
- findOne: async () => null,
962
- create: async (_o: string, d: any) => d,
963
- update: async (_o: string, _i: any, d: any) => d,
964
- delete: async () => true,
965
- syncSchema: async (object: string) => {
966
- operations.push(`syncSchema:${object}`);
967
- },
968
- };
969
-
970
- await kernel.use({
971
- name: 'mock-order-driver',
972
- type: 'driver',
973
- version: '1.0.0',
974
- init: async (ctx) => {
975
- ctx.registerService('driver.order', mockDriver);
976
- },
977
- });
978
-
979
- const plugin = new ObjectQLPlugin();
980
- await kernel.use(plugin);
981
-
982
- // Act
983
- await kernel.bootstrap();
984
-
985
- // Assert — loadMetaFromDb must appear before any syncSchema call
986
- const loadIdx = operations.indexOf('loadMetaFromDb');
987
- expect(loadIdx).toBeGreaterThanOrEqual(0);
988
-
989
- const firstSync = operations.findIndex((op) => op.startsWith('syncSchema:'));
990
- if (firstSync >= 0) {
991
- expect(loadIdx).toBeLessThan(firstSync);
992
- }
993
- });
994
- });
995
- });