@opensaas/stack-core 0.1.7 → 0.4.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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +352 -0
  3. package/CLAUDE.md +46 -1
  4. package/dist/access/engine.d.ts +7 -6
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +55 -0
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.d.ts +2 -0
  9. package/dist/access/engine.test.d.ts.map +1 -0
  10. package/dist/access/engine.test.js +125 -0
  11. package/dist/access/engine.test.js.map +1 -0
  12. package/dist/access/types.d.ts +39 -9
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +40 -20
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +34 -15
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin-engine.d.ts.map +1 -1
  19. package/dist/config/plugin-engine.js +9 -0
  20. package/dist/config/plugin-engine.js.map +1 -1
  21. package/dist/config/types.d.ts +277 -84
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +5 -3
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +146 -20
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts.map +1 -1
  28. package/dist/context/nested-operations.js +88 -72
  29. package/dist/context/nested-operations.js.map +1 -1
  30. package/dist/fields/index.d.ts +65 -9
  31. package/dist/fields/index.d.ts.map +1 -1
  32. package/dist/fields/index.js +98 -16
  33. package/dist/fields/index.js.map +1 -1
  34. package/dist/hooks/index.d.ts +28 -12
  35. package/dist/hooks/index.d.ts.map +1 -1
  36. package/dist/hooks/index.js +16 -0
  37. package/dist/hooks/index.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/mcp/handler.js +1 -0
  42. package/dist/mcp/handler.js.map +1 -1
  43. package/dist/validation/schema.d.ts.map +1 -1
  44. package/dist/validation/schema.js +4 -2
  45. package/dist/validation/schema.js.map +1 -1
  46. package/package.json +8 -9
  47. package/src/access/engine.test.ts +145 -0
  48. package/src/access/engine.ts +73 -9
  49. package/src/access/types.ts +38 -8
  50. package/src/config/index.ts +45 -23
  51. package/src/config/plugin-engine.ts +13 -3
  52. package/src/config/types.ts +347 -117
  53. package/src/context/index.ts +176 -23
  54. package/src/context/nested-operations.ts +83 -71
  55. package/src/fields/index.ts +132 -27
  56. package/src/hooks/index.ts +63 -20
  57. package/src/index.ts +9 -0
  58. package/src/mcp/handler.ts +2 -1
  59. package/src/validation/schema.ts +4 -2
  60. package/tests/context.test.ts +38 -6
  61. package/tests/field-types.test.ts +729 -0
  62. package/tests/password-type-distribution.test.ts +0 -1
  63. package/tests/password-types.test.ts +0 -1
  64. package/tests/plugin-engine.test.ts +1102 -0
  65. package/tests/sudo.test.ts +230 -2
  66. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,1102 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import {
3
+ executePlugins,
4
+ executeBeforeGenerateHooks,
5
+ executeAfterGenerateHooks,
6
+ } from '../src/config/plugin-engine.js'
7
+ import type { OpenSaasConfig, Plugin, PluginContext } from '../src/config/types.js'
8
+ import { text, integer } from '../src/fields/index.js'
9
+
10
+ describe('Plugin Engine', () => {
11
+ describe('dependency resolution', () => {
12
+ test('executes plugins with no dependencies', async () => {
13
+ const initSpy = vi.fn()
14
+ const plugin: Plugin = {
15
+ name: 'test-plugin',
16
+ init: initSpy,
17
+ }
18
+
19
+ const config: OpenSaasConfig = {
20
+ lists: {},
21
+ plugins: [plugin],
22
+ }
23
+
24
+ await executePlugins(config)
25
+ expect(initSpy).toHaveBeenCalledTimes(1)
26
+ })
27
+
28
+ test('sorts plugins by dependencies', async () => {
29
+ const executionOrder: string[] = []
30
+
31
+ const pluginA: Plugin = {
32
+ name: 'plugin-a',
33
+ init: async () => {
34
+ executionOrder.push('a')
35
+ },
36
+ }
37
+
38
+ const pluginB: Plugin = {
39
+ name: 'plugin-b',
40
+ dependencies: ['plugin-a'],
41
+ init: async () => {
42
+ executionOrder.push('b')
43
+ },
44
+ }
45
+
46
+ const pluginC: Plugin = {
47
+ name: 'plugin-c',
48
+ dependencies: ['plugin-b', 'plugin-a'],
49
+ init: async () => {
50
+ executionOrder.push('c')
51
+ },
52
+ }
53
+
54
+ const config: OpenSaasConfig = {
55
+ lists: {},
56
+ plugins: [pluginC, pluginB, pluginA], // Reverse order
57
+ }
58
+
59
+ await executePlugins(config)
60
+ expect(executionOrder).toEqual(['a', 'b', 'c'])
61
+ })
62
+
63
+ test('detects circular dependencies', async () => {
64
+ const pluginA: Plugin = {
65
+ name: 'plugin-a',
66
+ dependencies: ['plugin-b'],
67
+ init: async () => {},
68
+ }
69
+
70
+ const pluginB: Plugin = {
71
+ name: 'plugin-b',
72
+ dependencies: ['plugin-a'],
73
+ init: async () => {},
74
+ }
75
+
76
+ const config: OpenSaasConfig = {
77
+ lists: {},
78
+ plugins: [pluginA, pluginB],
79
+ }
80
+
81
+ await expect(executePlugins(config)).rejects.toThrow('Circular dependency detected')
82
+ })
83
+
84
+ test('throws on missing dependencies', async () => {
85
+ const plugin: Plugin = {
86
+ name: 'plugin-a',
87
+ dependencies: ['missing-plugin'],
88
+ init: async () => {},
89
+ }
90
+
91
+ const config: OpenSaasConfig = {
92
+ lists: {},
93
+ plugins: [plugin],
94
+ }
95
+
96
+ await expect(executePlugins(config)).rejects.toThrow(
97
+ 'Plugin "missing-plugin" is required by "plugin-a" but not found',
98
+ )
99
+ })
100
+
101
+ test('throws on duplicate plugin names', async () => {
102
+ const plugin1: Plugin = {
103
+ name: 'duplicate',
104
+ init: async () => {},
105
+ }
106
+
107
+ const plugin2: Plugin = {
108
+ name: 'duplicate',
109
+ init: async () => {},
110
+ }
111
+
112
+ const config: OpenSaasConfig = {
113
+ lists: {},
114
+ plugins: [plugin1, plugin2],
115
+ }
116
+
117
+ await expect(executePlugins(config)).rejects.toThrow('Duplicate plugin name: duplicate')
118
+ })
119
+
120
+ test('handles complex dependency chains', async () => {
121
+ const executionOrder: string[] = []
122
+
123
+ const plugins: Plugin[] = [
124
+ {
125
+ name: 'base',
126
+ init: async () => executionOrder.push('base'),
127
+ },
128
+ {
129
+ name: 'auth',
130
+ dependencies: ['base'],
131
+ init: async () => executionOrder.push('auth'),
132
+ },
133
+ {
134
+ name: 'profile',
135
+ dependencies: ['auth'],
136
+ init: async () => executionOrder.push('profile'),
137
+ },
138
+ {
139
+ name: 'notifications',
140
+ dependencies: ['auth'],
141
+ init: async () => executionOrder.push('notifications'),
142
+ },
143
+ {
144
+ name: 'admin',
145
+ dependencies: ['auth', 'profile', 'notifications'],
146
+ init: async () => executionOrder.push('admin'),
147
+ },
148
+ ]
149
+
150
+ const config: OpenSaasConfig = {
151
+ lists: {},
152
+ plugins: plugins.reverse(), // Reversed to test sorting
153
+ }
154
+
155
+ await executePlugins(config)
156
+
157
+ // Verify base runs first, auth before profile/notifications, and admin last
158
+ expect(executionOrder.indexOf('base')).toBeLessThan(executionOrder.indexOf('auth'))
159
+ expect(executionOrder.indexOf('auth')).toBeLessThan(executionOrder.indexOf('profile'))
160
+ expect(executionOrder.indexOf('auth')).toBeLessThan(executionOrder.indexOf('notifications'))
161
+ expect(executionOrder.indexOf('profile')).toBeLessThan(executionOrder.indexOf('admin'))
162
+ expect(executionOrder.indexOf('notifications')).toBeLessThan(executionOrder.indexOf('admin'))
163
+ })
164
+ })
165
+
166
+ describe('plugin initialization', () => {
167
+ test('provides correct context to init', async () => {
168
+ let receivedContext: PluginContext | undefined
169
+
170
+ const plugin: Plugin = {
171
+ name: 'test-plugin',
172
+ init: async (context) => {
173
+ receivedContext = context
174
+ },
175
+ }
176
+
177
+ const config: OpenSaasConfig = {
178
+ lists: {
179
+ Post: {
180
+ fields: {
181
+ title: text(),
182
+ },
183
+ },
184
+ },
185
+ plugins: [plugin],
186
+ }
187
+
188
+ await executePlugins(config)
189
+
190
+ expect(receivedContext).toBeDefined()
191
+ expect(receivedContext!.config).toBeDefined()
192
+ expect(receivedContext!.config.lists.Post).toBeDefined()
193
+ expect(typeof receivedContext!.addList).toBe('function')
194
+ expect(typeof receivedContext!.extendList).toBe('function')
195
+ expect(typeof receivedContext!.registerFieldType).toBe('function')
196
+ expect(typeof receivedContext!.registerMcpTool).toBe('function')
197
+ expect(typeof receivedContext!.setPluginData).toBe('function')
198
+ })
199
+
200
+ test('stores plugin data correctly', async () => {
201
+ const pluginData = { apiKey: 'secret', enabled: true }
202
+
203
+ const plugin: Plugin = {
204
+ name: 'test-plugin',
205
+ init: async (context) => {
206
+ context.setPluginData('test-plugin', pluginData)
207
+ },
208
+ }
209
+
210
+ const config: OpenSaasConfig = {
211
+ lists: {},
212
+ plugins: [plugin],
213
+ }
214
+
215
+ const result = await executePlugins(config)
216
+
217
+ expect(result._pluginData).toBeDefined()
218
+ expect(result._pluginData!['test-plugin']).toEqual(pluginData)
219
+ })
220
+
221
+ test('allows multiple plugins to store data', async () => {
222
+ const plugin1: Plugin = {
223
+ name: 'plugin-1',
224
+ init: async (context) => {
225
+ context.setPluginData('plugin-1', { value: 1 })
226
+ },
227
+ }
228
+
229
+ const plugin2: Plugin = {
230
+ name: 'plugin-2',
231
+ init: async (context) => {
232
+ context.setPluginData('plugin-2', { value: 2 })
233
+ },
234
+ }
235
+
236
+ const config: OpenSaasConfig = {
237
+ lists: {},
238
+ plugins: [plugin1, plugin2],
239
+ }
240
+
241
+ const result = await executePlugins(config)
242
+
243
+ expect(result._pluginData!['plugin-1']).toEqual({ value: 1 })
244
+ expect(result._pluginData!['plugin-2']).toEqual({ value: 2 })
245
+ })
246
+
247
+ test('handles empty plugin list', async () => {
248
+ const config: OpenSaasConfig = {
249
+ lists: {},
250
+ plugins: [],
251
+ }
252
+
253
+ const result = await executePlugins(config)
254
+ expect(result).toEqual(config)
255
+ })
256
+
257
+ test('handles no plugins property', async () => {
258
+ const config: OpenSaasConfig = {
259
+ lists: {},
260
+ }
261
+
262
+ const result = await executePlugins(config)
263
+ expect(result).toEqual(config)
264
+ })
265
+ })
266
+
267
+ describe('addList', () => {
268
+ test('adds a new list to config', async () => {
269
+ const plugin: Plugin = {
270
+ name: 'test-plugin',
271
+ init: async (context) => {
272
+ context.addList('User', {
273
+ fields: {
274
+ email: text(),
275
+ age: integer(),
276
+ },
277
+ })
278
+ },
279
+ }
280
+
281
+ const config: OpenSaasConfig = {
282
+ lists: {},
283
+ plugins: [plugin],
284
+ }
285
+
286
+ const result = await executePlugins(config)
287
+
288
+ expect(result.lists.User).toBeDefined()
289
+ expect(result.lists.User.fields.email).toBeDefined()
290
+ expect(result.lists.User.fields.age).toBeDefined()
291
+ })
292
+
293
+ test('throws when adding duplicate list', async () => {
294
+ const plugin: Plugin = {
295
+ name: 'test-plugin',
296
+ init: async (context) => {
297
+ context.addList('Post', {
298
+ fields: {
299
+ title: text(),
300
+ },
301
+ })
302
+ },
303
+ }
304
+
305
+ const config: OpenSaasConfig = {
306
+ lists: {
307
+ Post: {
308
+ fields: {
309
+ content: text(),
310
+ },
311
+ },
312
+ },
313
+ plugins: [plugin],
314
+ }
315
+
316
+ await expect(executePlugins(config)).rejects.toThrow(
317
+ 'Plugin "test-plugin" tried to add list "Post" but it already exists',
318
+ )
319
+ })
320
+
321
+ test('multiple plugins can add different lists', async () => {
322
+ const plugin1: Plugin = {
323
+ name: 'plugin-1',
324
+ init: async (context) => {
325
+ context.addList('User', { fields: { email: text() } })
326
+ },
327
+ }
328
+
329
+ const plugin2: Plugin = {
330
+ name: 'plugin-2',
331
+ init: async (context) => {
332
+ context.addList('Session', { fields: { token: text() } })
333
+ },
334
+ }
335
+
336
+ const config: OpenSaasConfig = {
337
+ lists: {},
338
+ plugins: [plugin1, plugin2],
339
+ }
340
+
341
+ const result = await executePlugins(config)
342
+
343
+ expect(result.lists.User).toBeDefined()
344
+ expect(result.lists.Session).toBeDefined()
345
+ })
346
+ })
347
+
348
+ describe('extendList', () => {
349
+ test('extends existing list with new fields', async () => {
350
+ const plugin: Plugin = {
351
+ name: 'test-plugin',
352
+ init: async (context) => {
353
+ context.extendList('Post', {
354
+ fields: {
355
+ views: integer(),
356
+ },
357
+ })
358
+ },
359
+ }
360
+
361
+ const config: OpenSaasConfig = {
362
+ lists: {
363
+ Post: {
364
+ fields: {
365
+ title: text(),
366
+ },
367
+ },
368
+ },
369
+ plugins: [plugin],
370
+ }
371
+
372
+ const result = await executePlugins(config)
373
+
374
+ expect(result.lists.Post.fields.title).toBeDefined()
375
+ expect(result.lists.Post.fields.views).toBeDefined()
376
+ })
377
+
378
+ test('throws when extending non-existent list', async () => {
379
+ const plugin: Plugin = {
380
+ name: 'test-plugin',
381
+ init: async (context) => {
382
+ context.extendList('User', {
383
+ fields: {
384
+ name: text(),
385
+ },
386
+ })
387
+ },
388
+ }
389
+
390
+ const config: OpenSaasConfig = {
391
+ lists: {},
392
+ plugins: [plugin],
393
+ }
394
+
395
+ await expect(executePlugins(config)).rejects.toThrow(
396
+ 'Plugin "test-plugin" tried to extend list "User" but it doesn\'t exist',
397
+ )
398
+ })
399
+
400
+ test('merges fields from multiple plugins', async () => {
401
+ const plugin1: Plugin = {
402
+ name: 'plugin-1',
403
+ init: async (context) => {
404
+ context.extendList('Post', {
405
+ fields: { views: integer() },
406
+ })
407
+ },
408
+ }
409
+
410
+ const plugin2: Plugin = {
411
+ name: 'plugin-2',
412
+ init: async (context) => {
413
+ context.extendList('Post', {
414
+ fields: { likes: integer() },
415
+ })
416
+ },
417
+ }
418
+
419
+ const config: OpenSaasConfig = {
420
+ lists: {
421
+ Post: {
422
+ fields: {
423
+ title: text(),
424
+ },
425
+ },
426
+ },
427
+ plugins: [plugin1, plugin2],
428
+ }
429
+
430
+ const result = await executePlugins(config)
431
+
432
+ expect(result.lists.Post.fields.title).toBeDefined()
433
+ expect(result.lists.Post.fields.views).toBeDefined()
434
+ expect(result.lists.Post.fields.likes).toBeDefined()
435
+ })
436
+ })
437
+
438
+ describe('hook merging', () => {
439
+ test('merges resolveInput hooks from multiple plugins', async () => {
440
+ const values: string[] = []
441
+
442
+ const plugin1: Plugin = {
443
+ name: 'plugin-1',
444
+ init: async (context) => {
445
+ context.extendList('Post', {
446
+ hooks: {
447
+ resolveInput: async ({ resolvedData }) => {
448
+ values.push('plugin-1')
449
+ return { ...resolvedData, field1: 'value1' }
450
+ },
451
+ },
452
+ })
453
+ },
454
+ }
455
+
456
+ const plugin2: Plugin = {
457
+ name: 'plugin-2',
458
+ init: async (context) => {
459
+ context.extendList('Post', {
460
+ hooks: {
461
+ resolveInput: async ({ resolvedData }) => {
462
+ values.push('plugin-2')
463
+ return { ...resolvedData, field2: 'value2' }
464
+ },
465
+ },
466
+ })
467
+ },
468
+ }
469
+
470
+ const config: OpenSaasConfig = {
471
+ lists: {
472
+ Post: {
473
+ fields: { title: text() },
474
+ },
475
+ },
476
+ plugins: [plugin1, plugin2],
477
+ }
478
+
479
+ const result = await executePlugins(config)
480
+
481
+ // Test that hooks are chained
482
+ const resolvedData = await result.lists.Post.hooks!.resolveInput!({
483
+ operation: 'create',
484
+ resolvedData: { title: 'test' },
485
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
+ } as any)
487
+
488
+ expect(values).toEqual(['plugin-1', 'plugin-2'])
489
+ expect(resolvedData).toEqual({
490
+ title: 'test',
491
+ field1: 'value1',
492
+ field2: 'value2',
493
+ })
494
+ })
495
+
496
+ test('merges validateInput hooks from multiple plugins', async () => {
497
+ const validations: string[] = []
498
+
499
+ const plugin1: Plugin = {
500
+ name: 'plugin-1',
501
+ init: async (context) => {
502
+ context.extendList('Post', {
503
+ hooks: {
504
+ validateInput: async () => {
505
+ validations.push('plugin-1')
506
+ },
507
+ },
508
+ })
509
+ },
510
+ }
511
+
512
+ const plugin2: Plugin = {
513
+ name: 'plugin-2',
514
+ init: async (context) => {
515
+ context.extendList('Post', {
516
+ hooks: {
517
+ validateInput: async () => {
518
+ validations.push('plugin-2')
519
+ },
520
+ },
521
+ })
522
+ },
523
+ }
524
+
525
+ const config: OpenSaasConfig = {
526
+ lists: {
527
+ Post: {
528
+ fields: { title: text() },
529
+ },
530
+ },
531
+ plugins: [plugin1, plugin2],
532
+ }
533
+
534
+ const result = await executePlugins(config)
535
+
536
+ // Test that both validators run
537
+ await result.lists.Post.hooks!.validateInput!({
538
+ operation: 'create',
539
+ resolvedData: { title: 'test' },
540
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
+ } as any)
542
+
543
+ expect(validations).toEqual(['plugin-1', 'plugin-2'])
544
+ })
545
+
546
+ test('merges beforeOperation hooks from multiple plugins', async () => {
547
+ const operations: string[] = []
548
+
549
+ const plugin1: Plugin = {
550
+ name: 'plugin-1',
551
+ init: async (context) => {
552
+ context.extendList('Post', {
553
+ hooks: {
554
+ beforeOperation: async () => {
555
+ operations.push('plugin-1')
556
+ },
557
+ },
558
+ })
559
+ },
560
+ }
561
+
562
+ const plugin2: Plugin = {
563
+ name: 'plugin-2',
564
+ init: async (context) => {
565
+ context.extendList('Post', {
566
+ hooks: {
567
+ beforeOperation: async () => {
568
+ operations.push('plugin-2')
569
+ },
570
+ },
571
+ })
572
+ },
573
+ }
574
+
575
+ const config: OpenSaasConfig = {
576
+ lists: {
577
+ Post: {
578
+ fields: { title: text() },
579
+ },
580
+ },
581
+ plugins: [plugin1, plugin2],
582
+ }
583
+
584
+ const result = await executePlugins(config)
585
+
586
+ // Test that both hooks run
587
+ await result.lists.Post.hooks!.beforeOperation!({
588
+ operation: 'create',
589
+ resolvedData: { title: 'test' },
590
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
591
+ } as any)
592
+
593
+ expect(operations).toEqual(['plugin-1', 'plugin-2'])
594
+ })
595
+
596
+ test('merges afterOperation hooks from multiple plugins', async () => {
597
+ const operations: string[] = []
598
+
599
+ const plugin1: Plugin = {
600
+ name: 'plugin-1',
601
+ init: async (context) => {
602
+ context.extendList('Post', {
603
+ hooks: {
604
+ afterOperation: async () => {
605
+ operations.push('plugin-1')
606
+ },
607
+ },
608
+ })
609
+ },
610
+ }
611
+
612
+ const plugin2: Plugin = {
613
+ name: 'plugin-2',
614
+ init: async (context) => {
615
+ context.extendList('Post', {
616
+ hooks: {
617
+ afterOperation: async () => {
618
+ operations.push('plugin-2')
619
+ },
620
+ },
621
+ })
622
+ },
623
+ }
624
+
625
+ const config: OpenSaasConfig = {
626
+ lists: {
627
+ Post: {
628
+ fields: { title: text() },
629
+ },
630
+ },
631
+ plugins: [plugin1, plugin2],
632
+ }
633
+
634
+ const result = await executePlugins(config)
635
+
636
+ // Test that both hooks run
637
+ await result.lists.Post.hooks!.afterOperation!({
638
+ operation: 'create',
639
+ item: { id: '1', title: 'test' },
640
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
641
+ } as any)
642
+
643
+ expect(operations).toEqual(['plugin-1', 'plugin-2'])
644
+ })
645
+
646
+ test('handles single hook without merging', async () => {
647
+ const plugin: Plugin = {
648
+ name: 'plugin-1',
649
+ init: async (context) => {
650
+ context.extendList('Post', {
651
+ hooks: {
652
+ resolveInput: async ({ resolvedData }) => {
653
+ return { ...resolvedData, modified: true }
654
+ },
655
+ },
656
+ })
657
+ },
658
+ }
659
+
660
+ const config: OpenSaasConfig = {
661
+ lists: {
662
+ Post: {
663
+ fields: { title: text() },
664
+ },
665
+ },
666
+ plugins: [plugin],
667
+ }
668
+
669
+ const result = await executePlugins(config)
670
+
671
+ const resolvedData = await result.lists.Post.hooks!.resolveInput!({
672
+ operation: 'create',
673
+ resolvedData: { title: 'test' },
674
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
675
+ } as any)
676
+
677
+ expect(resolvedData).toEqual({
678
+ title: 'test',
679
+ modified: true,
680
+ })
681
+ })
682
+ })
683
+
684
+ describe('access control merging', () => {
685
+ test('merges access control from plugins', async () => {
686
+ const plugin: Plugin = {
687
+ name: 'test-plugin',
688
+ init: async (context) => {
689
+ context.extendList('Post', {
690
+ access: {
691
+ operation: {
692
+ query: () => true,
693
+ create: ({ session }) => !!session,
694
+ },
695
+ },
696
+ })
697
+ },
698
+ }
699
+
700
+ const config: OpenSaasConfig = {
701
+ lists: {
702
+ Post: {
703
+ fields: { title: text() },
704
+ access: {
705
+ operation: {
706
+ update: ({ session }) => !!session,
707
+ },
708
+ },
709
+ },
710
+ },
711
+ plugins: [plugin],
712
+ }
713
+
714
+ const result = await executePlugins(config)
715
+
716
+ expect(result.lists.Post.access?.operation?.query).toBeDefined()
717
+ expect(result.lists.Post.access?.operation?.create).toBeDefined()
718
+ expect(result.lists.Post.access?.operation?.update).toBeDefined()
719
+ })
720
+
721
+ test('plugin access control overrides existing', async () => {
722
+ const pluginQuery = vi.fn(() => false)
723
+
724
+ const plugin: Plugin = {
725
+ name: 'test-plugin',
726
+ init: async (context) => {
727
+ context.extendList('Post', {
728
+ access: {
729
+ operation: {
730
+ query: pluginQuery,
731
+ },
732
+ },
733
+ })
734
+ },
735
+ }
736
+
737
+ const originalQuery = vi.fn(() => true)
738
+
739
+ const config: OpenSaasConfig = {
740
+ lists: {
741
+ Post: {
742
+ fields: { title: text() },
743
+ access: {
744
+ operation: {
745
+ query: originalQuery,
746
+ },
747
+ },
748
+ },
749
+ },
750
+ plugins: [plugin],
751
+ }
752
+
753
+ const result = await executePlugins(config)
754
+
755
+ // Plugin's access control should override original
756
+ expect(result.lists.Post.access?.operation?.query).toBe(pluginQuery)
757
+ })
758
+ })
759
+
760
+ describe('MCP integration', () => {
761
+ test('registers MCP tools from plugins', async () => {
762
+ const customTool = {
763
+ name: 'customTool',
764
+ listKey: 'Post',
765
+ handler: async () => ({ success: true }),
766
+ description: 'Custom tool',
767
+ inputSchema: {
768
+ type: 'object' as const,
769
+ properties: {},
770
+ },
771
+ }
772
+
773
+ const plugin: Plugin = {
774
+ name: 'test-plugin',
775
+ init: async (context) => {
776
+ context.registerMcpTool(customTool)
777
+ },
778
+ }
779
+
780
+ const config: OpenSaasConfig = {
781
+ lists: {},
782
+ plugins: [plugin],
783
+ }
784
+
785
+ const result = await executePlugins(config)
786
+
787
+ expect(result._pluginData?.__mcpTools).toEqual([customTool])
788
+ })
789
+
790
+ test('merges MCP config on list extension', async () => {
791
+ const plugin: Plugin = {
792
+ name: 'test-plugin',
793
+ init: async (context) => {
794
+ context.extendList('Post', {
795
+ mcp: {
796
+ enabled: true,
797
+ customTools: [
798
+ {
799
+ name: 'publishPost',
800
+ handler: async () => ({ success: true }),
801
+ description: 'Publish a post',
802
+ inputSchema: {
803
+ type: 'object' as const,
804
+ properties: {},
805
+ },
806
+ },
807
+ ],
808
+ },
809
+ })
810
+ },
811
+ }
812
+
813
+ const config: OpenSaasConfig = {
814
+ lists: {
815
+ Post: {
816
+ fields: { title: text() },
817
+ mcp: {
818
+ enabled: false,
819
+ },
820
+ },
821
+ },
822
+ plugins: [plugin],
823
+ }
824
+
825
+ const result = await executePlugins(config)
826
+
827
+ expect(result.lists.Post.mcp?.enabled).toBe(true)
828
+ expect(result.lists.Post.mcp?.customTools).toHaveLength(1)
829
+ })
830
+ })
831
+
832
+ describe('field type registration', () => {
833
+ test('registers custom field types', async () => {
834
+ const customFieldBuilder = () => ({
835
+ type: 'customField',
836
+ getZodSchema: () => undefined,
837
+ getPrismaType: () => ({ type: 'String', modifiers: '' }),
838
+ getTypeScriptType: () => ({ type: 'string', optional: false }),
839
+ })
840
+
841
+ let registeredBuilder
842
+
843
+ const plugin: Plugin = {
844
+ name: 'test-plugin',
845
+ init: async (context) => {
846
+ context.registerFieldType('customField', customFieldBuilder)
847
+ // Store reference for testing
848
+ registeredBuilder = customFieldBuilder
849
+ },
850
+ }
851
+
852
+ const config: OpenSaasConfig = {
853
+ lists: {},
854
+ plugins: [plugin],
855
+ }
856
+
857
+ await executePlugins(config)
858
+
859
+ // Field type is registered in plugin context but not directly testable
860
+ // In real usage, it would be available for field creation
861
+ expect(registeredBuilder).toBeDefined()
862
+ })
863
+
864
+ test('throws when registering duplicate field type', async () => {
865
+ const customFieldBuilder = () => ({
866
+ type: 'customField',
867
+ getZodSchema: () => undefined,
868
+ getPrismaType: () => ({ type: 'String', modifiers: '' }),
869
+ getTypeScriptType: () => ({ type: 'string', optional: false }),
870
+ })
871
+
872
+ const plugin: Plugin = {
873
+ name: 'test-plugin',
874
+ init: async (context) => {
875
+ context.registerFieldType('customField', customFieldBuilder)
876
+ context.registerFieldType('customField', customFieldBuilder) // Duplicate
877
+ },
878
+ }
879
+
880
+ const config: OpenSaasConfig = {
881
+ lists: {},
882
+ plugins: [plugin],
883
+ }
884
+
885
+ await expect(executePlugins(config)).rejects.toThrow(
886
+ 'Plugin "test-plugin" tried to register field type "customField" but it\'s already registered',
887
+ )
888
+ })
889
+ })
890
+
891
+ describe('generation lifecycle hooks', () => {
892
+ test('executes beforeGenerate hooks', async () => {
893
+ const beforeGenerate = vi.fn((config: OpenSaasConfig) => {
894
+ return {
895
+ ...config,
896
+ lists: {
897
+ ...config.lists,
898
+ NewList: {
899
+ fields: {
900
+ title: text(),
901
+ },
902
+ },
903
+ },
904
+ }
905
+ })
906
+
907
+ const plugin: Plugin = {
908
+ name: 'test-plugin',
909
+ init: async () => {},
910
+ beforeGenerate,
911
+ }
912
+
913
+ const config: OpenSaasConfig = {
914
+ lists: {},
915
+ plugins: [plugin],
916
+ }
917
+
918
+ const result = await executeBeforeGenerateHooks(config)
919
+
920
+ expect(beforeGenerate).toHaveBeenCalledTimes(1)
921
+ expect(result.lists.NewList).toBeDefined()
922
+ })
923
+
924
+ test('chains beforeGenerate hooks', async () => {
925
+ const plugin1: Plugin = {
926
+ name: 'plugin-1',
927
+ init: async () => {},
928
+ beforeGenerate: async (config) => ({
929
+ ...config,
930
+ lists: {
931
+ ...config.lists,
932
+ List1: { fields: { field1: text() } },
933
+ },
934
+ }),
935
+ }
936
+
937
+ const plugin2: Plugin = {
938
+ name: 'plugin-2',
939
+ init: async () => {},
940
+ beforeGenerate: async (config) => ({
941
+ ...config,
942
+ lists: {
943
+ ...config.lists,
944
+ List2: { fields: { field2: text() } },
945
+ },
946
+ }),
947
+ }
948
+
949
+ const config: OpenSaasConfig = {
950
+ lists: {},
951
+ plugins: [plugin1, plugin2],
952
+ }
953
+
954
+ const result = await executeBeforeGenerateHooks(config)
955
+
956
+ expect(result.lists.List1).toBeDefined()
957
+ expect(result.lists.List2).toBeDefined()
958
+ })
959
+
960
+ test('executes afterGenerate hooks', async () => {
961
+ const afterGenerate = vi.fn((files) => {
962
+ return {
963
+ ...files,
964
+ prismaSchema: files.prismaSchema + '\n// Modified by plugin',
965
+ }
966
+ })
967
+
968
+ const plugin: Plugin = {
969
+ name: 'test-plugin',
970
+ init: async () => {},
971
+ afterGenerate,
972
+ }
973
+
974
+ const config: OpenSaasConfig = {
975
+ lists: {},
976
+ plugins: [plugin],
977
+ }
978
+
979
+ const files = {
980
+ prismaSchema: 'schema content',
981
+ types: 'types content',
982
+ context: 'context content',
983
+ }
984
+
985
+ const result = await executeAfterGenerateHooks(config, files)
986
+
987
+ expect(afterGenerate).toHaveBeenCalledTimes(1)
988
+ expect(result.prismaSchema).toContain('// Modified by plugin')
989
+ })
990
+
991
+ test('chains afterGenerate hooks', async () => {
992
+ const plugin1: Plugin = {
993
+ name: 'plugin-1',
994
+ init: async () => {},
995
+ afterGenerate: async (files) => ({
996
+ ...files,
997
+ prismaSchema: files.prismaSchema + '\n// Plugin 1',
998
+ }),
999
+ }
1000
+
1001
+ const plugin2: Plugin = {
1002
+ name: 'plugin-2',
1003
+ init: async () => {},
1004
+ afterGenerate: async (files) => ({
1005
+ ...files,
1006
+ prismaSchema: files.prismaSchema + '\n// Plugin 2',
1007
+ }),
1008
+ }
1009
+
1010
+ const config: OpenSaasConfig = {
1011
+ lists: {},
1012
+ plugins: [plugin1, plugin2],
1013
+ }
1014
+
1015
+ const files = {
1016
+ prismaSchema: 'schema',
1017
+ types: 'types',
1018
+ context: 'context',
1019
+ }
1020
+
1021
+ const result = await executeAfterGenerateHooks(config, files)
1022
+
1023
+ expect(result.prismaSchema).toBe('schema\n// Plugin 1\n// Plugin 2')
1024
+ })
1025
+
1026
+ test('handles no lifecycle hooks', async () => {
1027
+ const config: OpenSaasConfig = {
1028
+ lists: {},
1029
+ plugins: [],
1030
+ }
1031
+
1032
+ const configResult = await executeBeforeGenerateHooks(config)
1033
+ expect(configResult).toEqual(config)
1034
+
1035
+ const files = {
1036
+ prismaSchema: 'schema',
1037
+ types: 'types',
1038
+ context: 'context',
1039
+ }
1040
+
1041
+ const filesResult = await executeAfterGenerateHooks(config, files)
1042
+ expect(filesResult).toEqual(files)
1043
+ })
1044
+ })
1045
+
1046
+ describe('config preservation', () => {
1047
+ test('preserves original config properties', async () => {
1048
+ const plugin: Plugin = {
1049
+ name: 'test-plugin',
1050
+ init: async () => {},
1051
+ }
1052
+
1053
+ const config: OpenSaasConfig = {
1054
+ db: { provider: 'postgresql', url: 'postgresql://localhost' },
1055
+ storage: {
1056
+ provider: 's3',
1057
+ config: { bucket: 'my-bucket' },
1058
+ },
1059
+ lists: {
1060
+ Post: {
1061
+ fields: { title: text() },
1062
+ },
1063
+ },
1064
+ plugins: [plugin],
1065
+ }
1066
+
1067
+ const result = await executePlugins(config)
1068
+
1069
+ expect(result.db).toEqual(config.db)
1070
+ expect(result.storage).toEqual(config.storage)
1071
+ expect(result.lists.Post).toBeDefined()
1072
+ })
1073
+
1074
+ test('does not mutate original config', async () => {
1075
+ const plugin: Plugin = {
1076
+ name: 'test-plugin',
1077
+ init: async (context) => {
1078
+ context.addList('NewList', {
1079
+ fields: { title: text() },
1080
+ })
1081
+ },
1082
+ }
1083
+
1084
+ const config: OpenSaasConfig = {
1085
+ lists: {
1086
+ Post: {
1087
+ fields: { title: text() },
1088
+ },
1089
+ },
1090
+ plugins: [plugin],
1091
+ }
1092
+
1093
+ const originalListsKeys = Object.keys(config.lists)
1094
+
1095
+ await executePlugins(config)
1096
+
1097
+ // Original config should not have NewList
1098
+ expect(Object.keys(config.lists)).toEqual(originalListsKeys)
1099
+ expect(config.lists.NewList).toBeUndefined()
1100
+ })
1101
+ })
1102
+ })