@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +291 -0
- package/README.md +6 -3
- package/dist/access/engine.d.ts +2 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +8 -6
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.js +4 -0
- package/dist/access/engine.test.js.map +1 -1
- package/dist/access/types.d.ts +31 -4
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +12 -10
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +37 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +341 -82
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +330 -60
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +38 -25
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/hooks/index.d.ts +45 -7
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +10 -4
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/engine.test.ts +4 -0
- package/src/access/engine.ts +10 -7
- package/src/access/types.ts +45 -4
- package/src/config/index.ts +65 -9
- package/src/config/types.ts +402 -91
- package/src/context/index.ts +421 -82
- package/src/context/nested-operations.ts +40 -25
- package/src/hooks/index.ts +66 -14
- package/src/index.ts +11 -0
- package/tests/access.test.ts +28 -28
- package/tests/config.test.ts +20 -3
- package/tests/nested-access-and-hooks.test.ts +8 -3
- package/tests/singleton.test.ts +329 -0
- package/tests/sudo.test.ts +2 -13
- 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
|
+
})
|
package/tests/sudo.test.ts
CHANGED
|
@@ -31,9 +31,9 @@ describe('Sudo Context', () => {
|
|
|
31
31
|
title: text({
|
|
32
32
|
validation: { isRequired: true },
|
|
33
33
|
hooks: {
|
|
34
|
-
resolveInput: async ({
|
|
34
|
+
resolveInput: async ({ resolvedData, fieldKey }) => {
|
|
35
35
|
hookExecutions.push('field-resolveInput')
|
|
36
|
-
return
|
|
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', () => {
|