@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.
@@ -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
@@ -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: createFindMany(listName, listConfig, prisma, context, config),
445
+ findMany: findManyOp,
444
446
  create: createOp,
445
- update: createUpdate(listName, listConfig, prisma, context, config),
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
  */
@@ -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
  })