@opensaas/stack-core 0.12.1 → 0.14.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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +291 -0
  3. package/README.md +6 -3
  4. package/dist/access/engine.d.ts +2 -0
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +8 -6
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.js +4 -0
  9. package/dist/access/engine.test.js.map +1 -1
  10. package/dist/access/types.d.ts +31 -4
  11. package/dist/access/types.d.ts.map +1 -1
  12. package/dist/config/index.d.ts +12 -10
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +37 -1
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/types.d.ts +341 -82
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +330 -60
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/context/nested-operations.d.ts.map +1 -1
  22. package/dist/context/nested-operations.js +38 -25
  23. package/dist/context/nested-operations.js.map +1 -1
  24. package/dist/hooks/index.d.ts +45 -7
  25. package/dist/hooks/index.d.ts.map +1 -1
  26. package/dist/hooks/index.js +10 -4
  27. package/dist/hooks/index.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/access/engine.test.ts +4 -0
  33. package/src/access/engine.ts +10 -7
  34. package/src/access/types.ts +45 -4
  35. package/src/config/index.ts +65 -9
  36. package/src/config/types.ts +402 -91
  37. package/src/context/index.ts +421 -82
  38. package/src/context/nested-operations.ts +40 -25
  39. package/src/hooks/index.ts +66 -14
  40. package/src/index.ts +11 -0
  41. package/tests/access.test.ts +28 -28
  42. package/tests/config.test.ts +20 -3
  43. package/tests/nested-access-and-hooks.test.ts +8 -3
  44. package/tests/singleton.test.ts +329 -0
  45. package/tests/sudo.test.ts +2 -13
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,329 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { getContext } from '../src/context/index.js'
3
+ import type { OpenSaasConfig } from '../src/config/types.js'
4
+ import { ValidationError } from '../src/hooks/index.js'
5
+
6
+ describe('Singleton Lists', () => {
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ let mockPrisma: any
9
+ let config: OpenSaasConfig
10
+
11
+ beforeEach(() => {
12
+ // Mock Prisma client
13
+ mockPrisma = {
14
+ settings: {
15
+ findFirst: vi.fn(),
16
+ findUnique: vi.fn(),
17
+ findMany: vi.fn(),
18
+ create: vi.fn(),
19
+ update: vi.fn(),
20
+ delete: vi.fn(),
21
+ count: vi.fn(),
22
+ },
23
+ post: {
24
+ findFirst: vi.fn(),
25
+ findUnique: vi.fn(),
26
+ findMany: vi.fn(),
27
+ create: vi.fn(),
28
+ update: vi.fn(),
29
+ delete: vi.fn(),
30
+ count: vi.fn(),
31
+ },
32
+ }
33
+
34
+ // Config with a singleton list
35
+ config = {
36
+ db: {
37
+ provider: 'postgresql',
38
+ url: 'postgresql://localhost:5432/test',
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ prismaClientConstructor: (PrismaClient: any) => new PrismaClient(),
41
+ },
42
+ lists: {
43
+ Settings: {
44
+ fields: {
45
+ siteName: { type: 'text', defaultValue: 'My Site' },
46
+ maintenanceMode: { type: 'checkbox', defaultValue: false },
47
+ maxUploadSize: { type: 'integer', defaultValue: 10 },
48
+ },
49
+ access: {
50
+ operation: {
51
+ query: () => true,
52
+ create: () => true,
53
+ update: () => true,
54
+ delete: () => true,
55
+ },
56
+ },
57
+ isSingleton: true,
58
+ },
59
+ Post: {
60
+ fields: {
61
+ title: { type: 'text' },
62
+ content: { type: 'text' },
63
+ },
64
+ access: {
65
+ operation: {
66
+ query: () => true,
67
+ create: () => true,
68
+ update: () => true,
69
+ delete: () => true,
70
+ },
71
+ },
72
+ },
73
+ },
74
+ }
75
+ })
76
+
77
+ describe('create operation', () => {
78
+ it('should allow creating the first record', async () => {
79
+ mockPrisma.settings.count.mockResolvedValue(0)
80
+ mockPrisma.settings.create.mockResolvedValue({
81
+ id: '1',
82
+ siteName: 'Test Site',
83
+ maintenanceMode: false,
84
+ maxUploadSize: 10,
85
+ createdAt: new Date(),
86
+ updatedAt: new Date(),
87
+ })
88
+
89
+ const context = getContext(config, mockPrisma, null)
90
+
91
+ const result = await context.db.settings.create({
92
+ data: { siteName: 'Test Site' },
93
+ })
94
+
95
+ expect(result).toBeDefined()
96
+ expect(mockPrisma.settings.count).toHaveBeenCalled()
97
+ expect(mockPrisma.settings.create).toHaveBeenCalled()
98
+ })
99
+
100
+ it('should prevent creating a second record', async () => {
101
+ mockPrisma.settings.count.mockResolvedValue(1)
102
+
103
+ const context = getContext(config, mockPrisma, null)
104
+
105
+ await expect(
106
+ context.db.settings.create({
107
+ data: { siteName: 'Second Site' },
108
+ }),
109
+ ).rejects.toThrow(ValidationError)
110
+
111
+ await expect(
112
+ context.db.settings.create({
113
+ data: { siteName: 'Second Site' },
114
+ }),
115
+ ).rejects.toThrow('singleton list with an existing record')
116
+ })
117
+
118
+ it('should enforce singleton even in sudo mode', async () => {
119
+ mockPrisma.settings.count.mockResolvedValue(1)
120
+
121
+ const context = getContext(config, mockPrisma, null)
122
+ const sudoContext = context.sudo()
123
+
124
+ await expect(
125
+ sudoContext.db.settings.create({
126
+ data: { siteName: 'Second Site' },
127
+ }),
128
+ ).rejects.toThrow(ValidationError)
129
+ })
130
+ })
131
+
132
+ describe('get operation', () => {
133
+ it('should have get() method for singleton lists', () => {
134
+ const context = getContext(config, mockPrisma, null)
135
+
136
+ expect(context.db.settings.get).toBeDefined()
137
+ expect(typeof context.db.settings.get).toBe('function')
138
+ })
139
+
140
+ it('should not have get() method for non-singleton lists', () => {
141
+ const context = getContext(config, mockPrisma, null)
142
+
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
+ expect((context.db.post as any).get).toBeUndefined()
145
+ })
146
+
147
+ it('should return existing record on get()', async () => {
148
+ const mockSettings = {
149
+ id: '1',
150
+ siteName: 'My Site',
151
+ maintenanceMode: false,
152
+ maxUploadSize: 10,
153
+ createdAt: new Date(),
154
+ updatedAt: new Date(),
155
+ }
156
+
157
+ mockPrisma.settings.findFirst.mockResolvedValue(mockSettings)
158
+
159
+ const context = getContext(config, mockPrisma, null)
160
+ const result = await context.db.settings.get()
161
+
162
+ expect(result).toBeDefined()
163
+ expect(result?.siteName).toBe('My Site')
164
+ expect(mockPrisma.settings.findFirst).toHaveBeenCalled()
165
+ })
166
+
167
+ it('should auto-create record with defaults when none exists (autoCreate: true by default)', async () => {
168
+ mockPrisma.settings.findFirst.mockResolvedValue(null)
169
+ mockPrisma.settings.count.mockResolvedValue(0)
170
+ mockPrisma.settings.create.mockResolvedValue({
171
+ id: '1',
172
+ siteName: 'My Site',
173
+ maintenanceMode: false,
174
+ maxUploadSize: 10,
175
+ createdAt: new Date(),
176
+ updatedAt: new Date(),
177
+ })
178
+
179
+ const context = getContext(config, mockPrisma, null)
180
+ const result = await context.db.settings.get()
181
+
182
+ expect(result).toBeDefined()
183
+ expect(result?.siteName).toBe('My Site')
184
+ expect(mockPrisma.settings.create).toHaveBeenCalledWith({
185
+ data: {
186
+ siteName: 'My Site',
187
+ maintenanceMode: false,
188
+ maxUploadSize: 10,
189
+ },
190
+ })
191
+ })
192
+
193
+ it('should not auto-create when autoCreate is false', async () => {
194
+ // Update config to disable auto-create
195
+ config.lists.Settings.isSingleton = { autoCreate: false }
196
+
197
+ mockPrisma.settings.findFirst.mockResolvedValue(null)
198
+
199
+ const context = getContext(config, mockPrisma, null)
200
+ const result = await context.db.settings.get()
201
+
202
+ expect(result).toBeNull()
203
+ expect(mockPrisma.settings.create).not.toHaveBeenCalled()
204
+ })
205
+ })
206
+
207
+ describe('delete operation', () => {
208
+ it('should block delete on singleton lists', async () => {
209
+ mockPrisma.settings.findUnique.mockResolvedValue({
210
+ id: '1',
211
+ siteName: 'My Site',
212
+ maintenanceMode: false,
213
+ maxUploadSize: 10,
214
+ })
215
+
216
+ const context = getContext(config, mockPrisma, null)
217
+
218
+ await expect(context.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
219
+ ValidationError,
220
+ )
221
+
222
+ await expect(context.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
223
+ 'singleton list',
224
+ )
225
+ })
226
+
227
+ it('should block delete even in sudo mode', async () => {
228
+ mockPrisma.settings.findUnique.mockResolvedValue({
229
+ id: '1',
230
+ siteName: 'My Site',
231
+ maintenanceMode: false,
232
+ maxUploadSize: 10,
233
+ })
234
+
235
+ const context = getContext(config, mockPrisma, null)
236
+ const sudoContext = context.sudo()
237
+
238
+ await expect(sudoContext.db.settings.delete({ where: { id: '1' } })).rejects.toThrow(
239
+ ValidationError,
240
+ )
241
+ })
242
+ })
243
+
244
+ describe('findMany operation', () => {
245
+ it('should block findMany on singleton lists', async () => {
246
+ const context = getContext(config, mockPrisma, null)
247
+
248
+ await expect(context.db.settings.findMany()).rejects.toThrow(ValidationError)
249
+
250
+ await expect(context.db.settings.findMany()).rejects.toThrow('Cannot use findMany')
251
+ })
252
+
253
+ it('should allow findMany on non-singleton lists', async () => {
254
+ mockPrisma.post.findMany.mockResolvedValue([
255
+ { id: '1', title: 'Post 1', content: 'Content 1' },
256
+ ])
257
+
258
+ const context = getContext(config, mockPrisma, null)
259
+ const result = await context.db.post.findMany()
260
+
261
+ expect(result).toBeDefined()
262
+ expect(result).toHaveLength(1)
263
+ expect(mockPrisma.post.findMany).toHaveBeenCalled()
264
+ })
265
+ })
266
+
267
+ describe('update operation', () => {
268
+ it('should allow updating the singleton record', async () => {
269
+ mockPrisma.settings.findUnique.mockResolvedValue({
270
+ id: '1',
271
+ siteName: 'My Site',
272
+ maintenanceMode: false,
273
+ maxUploadSize: 10,
274
+ })
275
+
276
+ mockPrisma.settings.update.mockResolvedValue({
277
+ id: '1',
278
+ siteName: 'Updated Site',
279
+ maintenanceMode: true,
280
+ maxUploadSize: 20,
281
+ createdAt: new Date(),
282
+ updatedAt: new Date(),
283
+ })
284
+
285
+ const context = getContext(config, mockPrisma, null)
286
+
287
+ const result = await context.db.settings.update({
288
+ where: { id: '1' },
289
+ data: { siteName: 'Updated Site', maintenanceMode: true, maxUploadSize: 20 },
290
+ })
291
+
292
+ expect(result).toBeDefined()
293
+ expect(result?.siteName).toBe('Updated Site')
294
+ expect(mockPrisma.settings.update).toHaveBeenCalled()
295
+ })
296
+ })
297
+
298
+ describe('findUnique operation', () => {
299
+ it('should allow findUnique on singleton lists', async () => {
300
+ mockPrisma.settings.findFirst.mockResolvedValue({
301
+ id: '1',
302
+ siteName: 'My Site',
303
+ maintenanceMode: false,
304
+ maxUploadSize: 10,
305
+ createdAt: new Date(),
306
+ updatedAt: new Date(),
307
+ })
308
+
309
+ const context = getContext(config, mockPrisma, null)
310
+ const result = await context.db.settings.findUnique({ where: { id: '1' } })
311
+
312
+ expect(result).toBeDefined()
313
+ expect(result?.siteName).toBe('My Site')
314
+ expect(mockPrisma.settings.findFirst).toHaveBeenCalled()
315
+ })
316
+ })
317
+
318
+ describe('count operation', () => {
319
+ it('should allow count on singleton lists', async () => {
320
+ mockPrisma.settings.count.mockResolvedValue(1)
321
+
322
+ const context = getContext(config, mockPrisma, null)
323
+ const result = await context.db.settings.count()
324
+
325
+ expect(result).toBe(1)
326
+ expect(mockPrisma.settings.count).toHaveBeenCalled()
327
+ })
328
+ })
329
+ })
@@ -31,9 +31,9 @@ describe('Sudo Context', () => {
31
31
  title: text({
32
32
  validation: { isRequired: true },
33
33
  hooks: {
34
- resolveInput: async ({ inputValue }) => {
34
+ resolveInput: async ({ resolvedData, fieldKey }) => {
35
35
  hookExecutions.push('field-resolveInput')
36
- return inputValue
36
+ return resolvedData[fieldKey]
37
37
  },
38
38
  beforeOperation: async () => {
39
39
  hookExecutions.push('field-beforeOperation')
@@ -116,17 +116,6 @@ describe('Sudo Context', () => {
116
116
  const sudoResult = await sudoContext.db.post.findUnique({ where: { id: '1' } })
117
117
  expect(sudoResult?.secretField).toBe('secret-value')
118
118
  })
119
-
120
- it('should execute field afterOperation hooks with sudo()', async () => {
121
- const context = getContext(testConfig, mockPrisma, null)
122
- const sudoContext = context.sudo()
123
-
124
- mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Test Post' }])
125
-
126
- await sudoContext.db.post.findMany()
127
-
128
- expect(hookExecutions).toContain('field-afterOperation')
129
- })
130
119
  })
131
120
 
132
121
  describe('Create Operations', () => {