@opensaas/stack-core 0.15.0 → 0.17.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 +130 -0
- package/dist/access/types.d.ts +13 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +50 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +47 -2
- package/dist/context/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/types.ts +12 -0
- package/src/config/types.ts +50 -0
- package/src/context/index.ts +71 -2
- package/tests/context.test.ts +148 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/config/types.ts
CHANGED
|
@@ -618,6 +618,34 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
|
618
618
|
* ```
|
|
619
619
|
*/
|
|
620
620
|
foreignKey?: boolean | { map?: string }
|
|
621
|
+
/**
|
|
622
|
+
* Custom relation name for many-to-many relationships
|
|
623
|
+
* Overrides the global joinTableNaming setting
|
|
624
|
+
* Prisma will create an implicit join table named _relationName
|
|
625
|
+
* Only needs to be set on one side of a bidirectional relationship
|
|
626
|
+
*
|
|
627
|
+
* @example KeystoneJS-style naming for migration
|
|
628
|
+
* ```typescript
|
|
629
|
+
* Lesson: list({
|
|
630
|
+
* fields: {
|
|
631
|
+
* teachers: relationship({
|
|
632
|
+
* ref: 'Teacher.lessons',
|
|
633
|
+
* many: true,
|
|
634
|
+
* db: { relationName: 'Lesson_teachers' }
|
|
635
|
+
* // Prisma creates join table _Lesson_teachers
|
|
636
|
+
* })
|
|
637
|
+
* }
|
|
638
|
+
* })
|
|
639
|
+
*
|
|
640
|
+
* Teacher: list({
|
|
641
|
+
* fields: {
|
|
642
|
+
* lessons: relationship({ ref: 'Lesson.teachers', many: true })
|
|
643
|
+
* // Automatically uses same relationName from other side
|
|
644
|
+
* }
|
|
645
|
+
* })
|
|
646
|
+
* ```
|
|
647
|
+
*/
|
|
648
|
+
relationName?: string
|
|
621
649
|
/**
|
|
622
650
|
* Extend or modify the generated Prisma schema lines for this relationship field
|
|
623
651
|
* Receives the generated FK line (if applicable) and relation line
|
|
@@ -1192,6 +1220,28 @@ export type DatabaseConfig = {
|
|
|
1192
1220
|
// Different database adapters have varying type signatures that are hard to unify
|
|
1193
1221
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1194
1222
|
prismaClientConstructor: (PrismaClientClass: any) => any
|
|
1223
|
+
/**
|
|
1224
|
+
* Join table naming strategy for many-to-many relationships
|
|
1225
|
+
* - 'prisma': Use Prisma's default alphabetically-sorted naming (e.g., `_LessonToTeacher`)
|
|
1226
|
+
* - 'keystone': Use KeystoneJS-compatible naming based on field location (e.g., `_Lesson_teachers`)
|
|
1227
|
+
*
|
|
1228
|
+
* Default: 'prisma'
|
|
1229
|
+
*
|
|
1230
|
+
* **Important for KeystoneJS migration:**
|
|
1231
|
+
* When migrating from KeystoneJS, set this to 'keystone' to preserve existing join table names
|
|
1232
|
+
* and avoid data loss. Keystone names join tables as `_Model_fieldName` based on where the
|
|
1233
|
+
* relationship is defined in the schema.
|
|
1234
|
+
*
|
|
1235
|
+
* @example Preserve Keystone join table names during migration
|
|
1236
|
+
* ```typescript
|
|
1237
|
+
* db: {
|
|
1238
|
+
* provider: 'postgresql',
|
|
1239
|
+
* joinTableNaming: 'keystone', // Use KeystoneJS naming convention
|
|
1240
|
+
* // ... rest of config
|
|
1241
|
+
* }
|
|
1242
|
+
* ```
|
|
1243
|
+
*/
|
|
1244
|
+
joinTableNaming?: 'prisma' | 'keystone'
|
|
1195
1245
|
/**
|
|
1196
1246
|
* Optional function to extend or modify the generated Prisma schema
|
|
1197
1247
|
* Receives the generated schema as a string and should return the modified schema
|
package/src/context/index.ts
CHANGED
|
@@ -438,13 +438,25 @@ export function getContext<
|
|
|
438
438
|
|
|
439
439
|
// Create base operations
|
|
440
440
|
const createOp = createCreate(listName, listConfig, prisma, context, config)
|
|
441
|
+
const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
|
|
442
|
+
const updateOp = createUpdate(listName, listConfig, prisma, context, config)
|
|
441
443
|
const operations: Record<string, unknown> = {
|
|
442
444
|
findUnique: createFindUnique(listName, listConfig, prisma, context, config),
|
|
443
|
-
findMany:
|
|
445
|
+
findMany: findManyOp,
|
|
444
446
|
create: createOp,
|
|
445
|
-
update:
|
|
447
|
+
update: updateOp,
|
|
446
448
|
delete: createDelete(listName, listConfig, prisma, context),
|
|
447
449
|
count: createCount(listName, listConfig, prisma, context),
|
|
450
|
+
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
451
|
+
updateMany: createUpdateMany(
|
|
452
|
+
listName,
|
|
453
|
+
listConfig,
|
|
454
|
+
prisma,
|
|
455
|
+
context,
|
|
456
|
+
config,
|
|
457
|
+
findManyOp,
|
|
458
|
+
updateOp,
|
|
459
|
+
),
|
|
448
460
|
}
|
|
449
461
|
|
|
450
462
|
// Add get() method for singleton lists
|
|
@@ -914,6 +926,32 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
914
926
|
}
|
|
915
927
|
}
|
|
916
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Create createMany operation with access control and hooks
|
|
931
|
+
* Runs create in a loop to ensure all hooks and access control are executed for each item
|
|
932
|
+
*/
|
|
933
|
+
function createCreateMany<TPrisma extends PrismaClientLike>(
|
|
934
|
+
listName: string,
|
|
935
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
936
|
+
listConfig: ListConfig<any>,
|
|
937
|
+
prisma: TPrisma,
|
|
938
|
+
context: AccessContext<TPrisma>,
|
|
939
|
+
config: OpenSaasConfig,
|
|
940
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
941
|
+
createFn: any,
|
|
942
|
+
) {
|
|
943
|
+
return async (args: { data: Record<string, unknown>[] }) => {
|
|
944
|
+
const results = []
|
|
945
|
+
|
|
946
|
+
for (const item of args.data) {
|
|
947
|
+
const result = await createFn({ data: item })
|
|
948
|
+
results.push(result)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return results
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
917
955
|
/**
|
|
918
956
|
* Create update operation with access control and hooks
|
|
919
957
|
*/
|
|
@@ -1096,6 +1134,37 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
1096
1134
|
}
|
|
1097
1135
|
}
|
|
1098
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* Create updateMany operation with access control and hooks
|
|
1139
|
+
* Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
|
|
1140
|
+
*/
|
|
1141
|
+
function createUpdateMany<TPrisma extends PrismaClientLike>(
|
|
1142
|
+
listName: string,
|
|
1143
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1144
|
+
listConfig: ListConfig<any>,
|
|
1145
|
+
prisma: TPrisma,
|
|
1146
|
+
context: AccessContext<TPrisma>,
|
|
1147
|
+
config: OpenSaasConfig,
|
|
1148
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1149
|
+
findManyFn: any,
|
|
1150
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1151
|
+
updateFn: any,
|
|
1152
|
+
) {
|
|
1153
|
+
return async (args: { where?: Record<string, unknown>; data: Record<string, unknown> }) => {
|
|
1154
|
+
// First, find all matching records (respects access control)
|
|
1155
|
+
const items = await findManyFn({ where: args.where })
|
|
1156
|
+
|
|
1157
|
+
// Then update each one individually (runs hooks and access control for each)
|
|
1158
|
+
const results = []
|
|
1159
|
+
for (const item of items) {
|
|
1160
|
+
const result = await updateFn({ where: { id: item.id }, data: args.data })
|
|
1161
|
+
results.push(result)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return results
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1099
1168
|
/**
|
|
1100
1169
|
* Create delete operation with access control and hooks
|
|
1101
1170
|
*/
|
package/tests/context.test.ts
CHANGED
|
@@ -276,5 +276,153 @@ describe('getContext', () => {
|
|
|
276
276
|
expect(mockPrisma.user.count).toHaveBeenCalled()
|
|
277
277
|
expect(result).toBe(5)
|
|
278
278
|
})
|
|
279
|
+
|
|
280
|
+
it('should batch create items via createMany', async () => {
|
|
281
|
+
const mockUsers = [
|
|
282
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
283
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com' },
|
|
284
|
+
{ id: '3', name: 'Bob', email: 'bob@example.com' },
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
// Mock create to return each user in sequence
|
|
288
|
+
mockPrisma.user.create
|
|
289
|
+
.mockResolvedValueOnce(mockUsers[0])
|
|
290
|
+
.mockResolvedValueOnce(mockUsers[1])
|
|
291
|
+
.mockResolvedValueOnce(mockUsers[2])
|
|
292
|
+
|
|
293
|
+
const context = await getContext(config, mockPrisma, null)
|
|
294
|
+
const result = await context.db.user.createMany({
|
|
295
|
+
data: [
|
|
296
|
+
{ name: 'John', email: 'john@example.com' },
|
|
297
|
+
{ name: 'Jane', email: 'jane@example.com' },
|
|
298
|
+
{ name: 'Bob', email: 'bob@example.com' },
|
|
299
|
+
],
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// Should call create 3 times (once for each item)
|
|
303
|
+
expect(mockPrisma.user.create).toHaveBeenCalledTimes(3)
|
|
304
|
+
expect(result).toEqual(mockUsers)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('should batch update items via updateMany', async () => {
|
|
308
|
+
const mockUsers = [
|
|
309
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
310
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com' },
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
const updatedUsers = [
|
|
314
|
+
{ id: '1', name: 'John Updated', email: 'john@example.com' },
|
|
315
|
+
{ id: '2', name: 'Jane Updated', email: 'jane@example.com' },
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
// Mock findMany to return the users
|
|
319
|
+
mockPrisma.user.findMany.mockResolvedValue(mockUsers)
|
|
320
|
+
|
|
321
|
+
// Mock findUnique for each update's access check
|
|
322
|
+
mockPrisma.user.findUnique
|
|
323
|
+
.mockResolvedValueOnce(mockUsers[0])
|
|
324
|
+
.mockResolvedValueOnce(mockUsers[1])
|
|
325
|
+
|
|
326
|
+
// Mock update to return updated users
|
|
327
|
+
mockPrisma.user.update
|
|
328
|
+
.mockResolvedValueOnce(updatedUsers[0])
|
|
329
|
+
.mockResolvedValueOnce(updatedUsers[1])
|
|
330
|
+
|
|
331
|
+
const context = await getContext(config, mockPrisma, null)
|
|
332
|
+
const result = await context.db.user.updateMany({
|
|
333
|
+
where: { id: { in: ['1', '2'] } },
|
|
334
|
+
data: { name: 'Updated' },
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// Should call findMany once to get records
|
|
338
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
|
|
339
|
+
where: { id: { in: ['1', '2'] } },
|
|
340
|
+
take: undefined,
|
|
341
|
+
skip: undefined,
|
|
342
|
+
include: undefined,
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// Should call update twice (once for each item)
|
|
346
|
+
expect(mockPrisma.user.update).toHaveBeenCalledTimes(2)
|
|
347
|
+
expect(result).toEqual(updatedUsers)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should run hooks and access control for each item in createMany', async () => {
|
|
351
|
+
// Test that hooks are called for each item
|
|
352
|
+
const mockUsers = [
|
|
353
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
354
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com' },
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
mockPrisma.user.create.mockResolvedValueOnce(mockUsers[0]).mockResolvedValueOnce(mockUsers[1])
|
|
358
|
+
|
|
359
|
+
// Config with hook
|
|
360
|
+
const configWithHook: OpenSaasConfig = {
|
|
361
|
+
...config,
|
|
362
|
+
lists: {
|
|
363
|
+
...config.lists,
|
|
364
|
+
User: {
|
|
365
|
+
...config.lists.User,
|
|
366
|
+
hooks: {
|
|
367
|
+
resolveInput: vi.fn(async ({ resolvedData }) => resolvedData),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const context = await getContext(configWithHook, mockPrisma, null)
|
|
374
|
+
await context.db.user.createMany({
|
|
375
|
+
data: [
|
|
376
|
+
{ name: 'John', email: 'john@example.com' },
|
|
377
|
+
{ name: 'Jane', email: 'jane@example.com' },
|
|
378
|
+
],
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Hook should be called twice (once for each item)
|
|
382
|
+
expect(configWithHook.lists.User.hooks?.resolveInput).toHaveBeenCalledTimes(2)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('should run hooks and access control for each item in updateMany', async () => {
|
|
386
|
+
const mockUsers = [
|
|
387
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
388
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com' },
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
const updatedUsers = [
|
|
392
|
+
{ id: '1', name: 'John Updated', email: 'john@example.com' },
|
|
393
|
+
{ id: '2', name: 'Jane Updated', email: 'jane@example.com' },
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
mockPrisma.user.findMany.mockResolvedValue(mockUsers)
|
|
397
|
+
mockPrisma.user.findUnique
|
|
398
|
+
.mockResolvedValueOnce(mockUsers[0])
|
|
399
|
+
.mockResolvedValueOnce(mockUsers[1])
|
|
400
|
+
mockPrisma.user.update
|
|
401
|
+
.mockResolvedValueOnce(updatedUsers[0])
|
|
402
|
+
.mockResolvedValueOnce(updatedUsers[1])
|
|
403
|
+
|
|
404
|
+
// Config with hook
|
|
405
|
+
const configWithHook: OpenSaasConfig = {
|
|
406
|
+
...config,
|
|
407
|
+
lists: {
|
|
408
|
+
...config.lists,
|
|
409
|
+
User: {
|
|
410
|
+
...config.lists.User,
|
|
411
|
+
hooks: {
|
|
412
|
+
resolveInput: vi.fn(async ({ resolvedData }) => resolvedData),
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const context = await getContext(configWithHook, mockPrisma, null)
|
|
419
|
+
await context.db.user.updateMany({
|
|
420
|
+
where: { id: { in: ['1', '2'] } },
|
|
421
|
+
data: { name: 'Updated' },
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// Hook should be called twice (once for each item)
|
|
425
|
+
expect(configWithHook.lists.User.hooks?.resolveInput).toHaveBeenCalledTimes(2)
|
|
426
|
+
})
|
|
279
427
|
})
|
|
280
428
|
})
|