@opensaas/stack-core 0.24.0 → 0.25.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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +223 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/types.d.ts +318 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/context/index.d.ts +19 -1
  22. package/dist/context/index.d.ts.map +1 -1
  23. package/dist/context/index.js +153 -26
  24. package/dist/context/index.js.map +1 -1
  25. package/dist/context/nested-operations.d.ts +59 -3
  26. package/dist/context/nested-operations.d.ts.map +1 -1
  27. package/dist/context/nested-operations.js +552 -129
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts +91 -0
  30. package/dist/context/transaction-boundary.d.ts.map +1 -0
  31. package/dist/context/transaction-boundary.js +329 -0
  32. package/dist/context/transaction-boundary.js.map +1 -0
  33. package/dist/context/write-pipeline.d.ts +15 -1
  34. package/dist/context/write-pipeline.d.ts.map +1 -1
  35. package/dist/context/write-pipeline.js +173 -10
  36. package/dist/context/write-pipeline.js.map +1 -1
  37. package/dist/fields/calendar-day.test.d.ts +2 -0
  38. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  39. package/dist/fields/calendar-day.test.js +120 -0
  40. package/dist/fields/calendar-day.test.js.map +1 -0
  41. package/dist/fields/index.d.ts +18 -2
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +93 -17
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/hooks/index.d.ts +116 -0
  46. package/dist/hooks/index.d.ts.map +1 -1
  47. package/dist/hooks/index.js +154 -0
  48. package/dist/hooks/index.js.map +1 -1
  49. package/dist/validation/schema.test.js +222 -1
  50. package/dist/validation/schema.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/access/access-filter.ts +156 -0
  53. package/src/access/field-access.test.ts +255 -0
  54. package/src/access/field-access.ts +91 -5
  55. package/src/access/index.ts +1 -1
  56. package/src/access/types.ts +45 -0
  57. package/src/config/types.ts +364 -0
  58. package/src/context/index.ts +207 -37
  59. package/src/context/nested-operations.ts +969 -143
  60. package/src/context/transaction-boundary.ts +440 -0
  61. package/src/context/write-pipeline.ts +234 -13
  62. package/src/fields/calendar-day.test.ts +140 -0
  63. package/src/fields/index.ts +96 -16
  64. package/src/hooks/index.ts +265 -0
  65. package/src/validation/schema.test.ts +266 -1
  66. package/tests/access.test.ts +24 -16
  67. package/tests/context.test.ts +481 -0
  68. package/tests/field-types.test.ts +17 -3
  69. package/tests/nested-access-and-hooks.test.ts +1130 -54
  70. package/tests/nested-operation-registry.test.ts +28 -3
  71. package/tests/nested-write-hooks.test.ts +864 -0
  72. package/tests/transaction-boundary-hooks.test.ts +465 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { getContext } from '../src/context/index.js'
3
+ import { defineFragment } from '../src/query/index.js'
4
+ import { virtual } from '../src/fields/index.js'
3
5
  import type { OpenSaasConfig } from '../src/config/types.js'
4
6
 
5
7
  describe('getContext', () => {
@@ -212,6 +214,131 @@ describe('getContext', () => {
212
214
  expect(result).toEqual(mockUser)
213
215
  })
214
216
 
217
+ describe('findUnique unique-where enforcement (#567)', () => {
218
+ it('accepts a valid unique where (id) and keeps access + include intact', async () => {
219
+ const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
220
+ mockPrisma.user.findFirst.mockResolvedValue(mockUser)
221
+
222
+ const context = await getContext(config, mockPrisma, null)
223
+ const result = await context.db.user.findUnique({
224
+ where: { id: '1' },
225
+ include: { posts: true },
226
+ })
227
+
228
+ // Access control still runs and the underlying delegate is invoked with
229
+ // the merged where + include (proving access + include path is intact).
230
+ expect(mockPrisma.user.findFirst).toHaveBeenCalledWith(
231
+ expect.objectContaining({
232
+ where: expect.objectContaining({ id: '1' }),
233
+ include: { posts: true },
234
+ }),
235
+ )
236
+ expect(result).toEqual(mockUser)
237
+ })
238
+
239
+ it('accepts a configured-unique field (email) as the unique where', async () => {
240
+ const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
241
+ mockPrisma.user.findFirst.mockResolvedValue(mockUser)
242
+
243
+ const context = await getContext(config, mockPrisma, null)
244
+ const result = await context.db.user.findUnique({
245
+ where: { email: 'john@example.com' },
246
+ })
247
+
248
+ expect(mockPrisma.user.findFirst).toHaveBeenCalled()
249
+ expect(result).toEqual(mockUser)
250
+ })
251
+
252
+ it('narrows the result through a query fragment with a unique where', async () => {
253
+ const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
254
+ mockPrisma.user.findFirst.mockResolvedValue(mockUser)
255
+
256
+ const fragment = defineFragment<{ id: string; name: string; email: string }>()({
257
+ id: true,
258
+ name: true,
259
+ } as const)
260
+
261
+ const context = await getContext(config, mockPrisma, null)
262
+ const result = await context.db.user.findUnique({ where: { id: '1' }, query: fragment })
263
+
264
+ // Fragment narrows the result to only the requested fields (email omitted)
265
+ expect(result).toEqual({ id: '1', name: 'John' })
266
+ })
267
+
268
+ it('THROWS on a non-unique where (caller-shape error, not a silent null)', async () => {
269
+ mockPrisma.user.findFirst.mockResolvedValue({ id: '1', name: 'John' })
270
+
271
+ const context = await getContext(config, mockPrisma, null)
272
+
273
+ // `name` is not a unique key — this is misuse and must throw, not return null.
274
+ await expect(context.db.user.findUnique({ where: { name: 'John' } })).rejects.toThrow(
275
+ /requires a unique `where`/,
276
+ )
277
+ // The error guides the caller toward findFirst (the non-unique escape hatch).
278
+ await expect(context.db.user.findUnique({ where: { name: 'John' } })).rejects.toThrow(
279
+ /findFirst/,
280
+ )
281
+ // Guard runs before any DB access.
282
+ expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
283
+ })
284
+
285
+ it('THROWS when a unique key is mixed with extra non-unique keys', async () => {
286
+ const context = await getContext(config, mockPrisma, null)
287
+
288
+ await expect(
289
+ context.db.user.findUnique({ where: { id: '1', name: 'John' } }),
290
+ ).rejects.toThrow(/requires a unique `where`/)
291
+ expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
292
+ })
293
+
294
+ it('THROWS on an empty where', async () => {
295
+ const context = await getContext(config, mockPrisma, null)
296
+
297
+ await expect(context.db.user.findUnique({ where: {} })).rejects.toThrow(
298
+ /requires a unique `where`/,
299
+ )
300
+ expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
301
+ })
302
+
303
+ it('returns null on access denial (silent-failure contract preserved)', async () => {
304
+ const deniedConfig: OpenSaasConfig = {
305
+ ...config,
306
+ lists: {
307
+ ...config.lists,
308
+ User: {
309
+ ...config.lists.User,
310
+ access: {
311
+ operation: {
312
+ query: () => false,
313
+ create: () => true,
314
+ update: () => true,
315
+ delete: () => true,
316
+ },
317
+ },
318
+ },
319
+ },
320
+ }
321
+ mockPrisma.user.findFirst.mockResolvedValue({ id: '1', name: 'John' })
322
+
323
+ const context = await getContext(deniedConfig, mockPrisma, null)
324
+ const result = await context.db.user.findUnique({ where: { id: '1' } })
325
+
326
+ // Access denied -> null (not a throw), and the DB is never queried.
327
+ expect(result).toBeNull()
328
+ expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
329
+ })
330
+
331
+ it('returns null when no record matches a valid unique where', async () => {
332
+ mockPrisma.user.findFirst.mockResolvedValue(null)
333
+
334
+ const context = await getContext(config, mockPrisma, null)
335
+ const result = await context.db.user.findUnique({ where: { id: 'missing' } })
336
+
337
+ expect(result).toBeNull()
338
+ expect(mockPrisma.user.findFirst).toHaveBeenCalled()
339
+ })
340
+ })
341
+
215
342
  it('should delegate findMany to prisma with access control', async () => {
216
343
  const mockUsers = [
217
344
  { id: '1', name: 'John' },
@@ -226,6 +353,360 @@ describe('getContext', () => {
226
353
  expect(result).toEqual(mockUsers)
227
354
  })
228
355
 
356
+ describe('findFirst', () => {
357
+ it('should return the first matching row', async () => {
358
+ const mockUsers = [
359
+ { id: '1', name: 'John', email: 'john@example.com' },
360
+ { id: '2', name: 'Jane', email: 'jane@example.com' },
361
+ ]
362
+ mockPrisma.user.findMany.mockResolvedValue(mockUsers)
363
+
364
+ const context = await getContext(config, mockPrisma, null)
365
+ const result = await context.db.user.findFirst()
366
+
367
+ // Delegates to the access-controlled findMany with take: 1
368
+ expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }))
369
+ expect(result).toEqual(mockUsers[0])
370
+ })
371
+
372
+ it('should return null (not undefined, not throw) when nothing matches', async () => {
373
+ mockPrisma.user.findMany.mockResolvedValue([])
374
+
375
+ const context = await getContext(config, mockPrisma, null)
376
+ const result = await context.db.user.findFirst({ where: { name: 'Nobody' } })
377
+
378
+ expect(result).toBeNull()
379
+ expect(result).not.toBeUndefined()
380
+ })
381
+
382
+ it('should respect where and orderBy', async () => {
383
+ const mockUser = { id: '2', name: 'Jane', email: 'jane@example.com' }
384
+ mockPrisma.user.findMany.mockResolvedValue([mockUser])
385
+
386
+ const context = await getContext(config, mockPrisma, null)
387
+ const result = await context.db.user.findFirst({
388
+ where: { name: 'Jane' },
389
+ orderBy: { name: 'asc' },
390
+ })
391
+
392
+ expect(mockPrisma.user.findMany).toHaveBeenCalledWith(
393
+ expect.objectContaining({
394
+ where: expect.objectContaining({ name: 'Jane' }),
395
+ orderBy: { name: 'asc' },
396
+ take: 1,
397
+ }),
398
+ )
399
+ expect(result).toEqual(mockUser)
400
+ })
401
+
402
+ it('should honour query-access denial the same as findMany (denied -> null)', async () => {
403
+ const deniedConfig: OpenSaasConfig = {
404
+ ...config,
405
+ lists: {
406
+ ...config.lists,
407
+ User: {
408
+ ...config.lists.User,
409
+ access: {
410
+ operation: {
411
+ query: () => false,
412
+ create: () => true,
413
+ update: () => true,
414
+ delete: () => true,
415
+ },
416
+ },
417
+ },
418
+ },
419
+ }
420
+ mockPrisma.user.findMany.mockResolvedValue([
421
+ { id: '1', name: 'John', email: 'john@example.com' },
422
+ ])
423
+
424
+ const context = await getContext(deniedConfig, mockPrisma, null)
425
+ const result = await context.db.user.findFirst()
426
+
427
+ // Denied query short-circuits before hitting prisma — exactly like findMany
428
+ expect(result).toBeNull()
429
+ expect(mockPrisma.user.findMany).not.toHaveBeenCalled()
430
+ })
431
+
432
+ it('should respect a query fragment, narrowing the returned single result', async () => {
433
+ const mockUsers = [
434
+ { id: '1', name: 'John', email: 'john@example.com' },
435
+ { id: '2', name: 'Jane', email: 'jane@example.com' },
436
+ ]
437
+ mockPrisma.user.findMany.mockResolvedValue(mockUsers)
438
+
439
+ const fragment = defineFragment<{ id: string; name: string; email: string }>()({
440
+ id: true,
441
+ name: true,
442
+ } as const)
443
+
444
+ const context = await getContext(config, mockPrisma, null)
445
+ const result = await context.db.user.findFirst({ query: fragment })
446
+
447
+ // Fragment narrows the result to only the requested fields (email omitted)
448
+ expect(result).toEqual({ id: '1', name: 'John' })
449
+ })
450
+ })
451
+
452
+ describe('explicit include merges with access control (#566)', () => {
453
+ // Author has many Posts; Post.query access scopes to published posts only.
454
+ // A caller-supplied `include` must NOT bypass that per-relation filter.
455
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
456
+ let relPrisma: any
457
+ let relConfig: OpenSaasConfig
458
+
459
+ beforeEach(() => {
460
+ relPrisma = {
461
+ author: {
462
+ findFirst: vi.fn(),
463
+ findUnique: vi.fn(),
464
+ findMany: vi.fn(),
465
+ },
466
+ post: {
467
+ findFirst: vi.fn(),
468
+ findUnique: vi.fn(),
469
+ findMany: vi.fn(),
470
+ },
471
+ comment: {
472
+ findFirst: vi.fn(),
473
+ findMany: vi.fn(),
474
+ },
475
+ }
476
+
477
+ relConfig = {
478
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
479
+ lists: {
480
+ Author: {
481
+ fields: {
482
+ name: { type: 'text' },
483
+ posts: { type: 'relationship', ref: 'Post.author', many: true },
484
+ },
485
+ access: { operation: { query: () => true } },
486
+ },
487
+ Post: {
488
+ fields: {
489
+ title: { type: 'text' },
490
+ author: { type: 'relationship', ref: 'Author.posts' },
491
+ comments: { type: 'relationship', ref: 'Comment.post', many: true },
492
+ },
493
+ access: {
494
+ // Row filter: only published posts are visible.
495
+ operation: { query: () => ({ status: { equals: 'published' } }) },
496
+ },
497
+ },
498
+ Comment: {
499
+ fields: {
500
+ body: { type: 'text' },
501
+ post: { type: 'relationship', ref: 'Post.comments' },
502
+ },
503
+ access: {
504
+ operation: { query: () => ({ approved: { equals: true } }) },
505
+ },
506
+ },
507
+ Secret: {
508
+ fields: {
509
+ value: { type: 'text' },
510
+ },
511
+ // Fully denied list.
512
+ access: { operation: { query: () => false } },
513
+ },
514
+ },
515
+ }
516
+ // Add a denied relation onto Author for the "drop denied relation" test.
517
+ relConfig.lists.Author.fields.secrets = {
518
+ type: 'relationship',
519
+ ref: 'Secret',
520
+ many: true,
521
+ }
522
+ })
523
+
524
+ it('findMany: caller include {posts:true} applies the relation access where (not bare true)', async () => {
525
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
526
+
527
+ const context = await getContext(relConfig, relPrisma, null)
528
+ await context.db.author.findMany({ include: { posts: true } })
529
+
530
+ // The relation is fetched WITH the Post query-access where (NOT bare true),
531
+ // proving the row-level bypass is closed.
532
+ const call = relPrisma.author.findMany.mock.calls[0][0]
533
+ expect(call.include.posts).not.toBe(true)
534
+ expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
535
+ })
536
+
537
+ it('findUnique: caller include {posts:true} applies the relation access where', async () => {
538
+ relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo', posts: [] })
539
+
540
+ const context = await getContext(relConfig, relPrisma, null)
541
+ await context.db.author.findUnique({ where: { id: 'a1' }, include: { posts: true } })
542
+
543
+ const call = relPrisma.author.findFirst.mock.calls[0][0]
544
+ expect(call.where).toEqual(expect.objectContaining({ id: 'a1' }))
545
+ expect(call.include.posts).not.toBe(true)
546
+ expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
547
+ })
548
+
549
+ it('drops a relation whose query access is false when named in the caller include', async () => {
550
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
551
+
552
+ const context = await getContext(relConfig, relPrisma, null)
553
+ await context.db.author.findMany({ include: { secrets: true, posts: true } })
554
+
555
+ const call = relPrisma.author.findMany.mock.calls[0][0]
556
+ // Denied `secrets` relation is dropped; allowed `posts` keeps its filter.
557
+ expect(call.include.secrets).toBeUndefined()
558
+ expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
559
+ })
560
+
561
+ it('AND-combines a caller nested where with the relation access where', async () => {
562
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
563
+
564
+ const context = await getContext(relConfig, relPrisma, null)
565
+ await context.db.author.findMany({
566
+ include: { posts: { where: { title: { contains: 'hello' } } } },
567
+ })
568
+
569
+ const call = relPrisma.author.findMany.mock.calls[0][0]
570
+ // Both the access where and the caller where are applied via AND.
571
+ expect(call.include.posts.where).toEqual({
572
+ AND: [{ status: { equals: 'published' } }, { title: { contains: 'hello' } }],
573
+ })
574
+ })
575
+
576
+ it('access-filters nested (2-level) caller includes at every level', async () => {
577
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
578
+
579
+ const context = await getContext(relConfig, relPrisma, null)
580
+ await context.db.author.findMany({
581
+ include: { posts: { include: { comments: true } } },
582
+ })
583
+
584
+ const call = relPrisma.author.findMany.mock.calls[0][0]
585
+ // Level 1 (posts) and level 2 (comments) both carry their access where.
586
+ expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
587
+ expect(call.include.posts.include.comments.where).toEqual({ approved: { equals: true } })
588
+ })
589
+
590
+ it('sudo with explicit include returns the include unfiltered (behaviour preserved)', async () => {
591
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
592
+
593
+ const context = await getContext(relConfig, relPrisma, null).sudo()
594
+ await context.db.author.findMany({ include: { posts: true, secrets: true } })
595
+
596
+ // Under sudo the caller include is used as-is: no filter, nothing dropped.
597
+ expect(relPrisma.author.findMany).toHaveBeenCalledWith(
598
+ expect.objectContaining({ include: { posts: true, secrets: true } }),
599
+ )
600
+ })
601
+
602
+ it('query fragment path is unaffected by the merge (fragment include used, unfiltered)', async () => {
603
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
604
+
605
+ const postsFragment = defineFragment<{ id: string; title: string }>()({
606
+ title: true,
607
+ } as const)
608
+ const fragment = defineFragment<{ id: string; name: string; posts: unknown }>()({
609
+ id: true,
610
+ name: true,
611
+ posts: postsFragment,
612
+ } as const)
613
+
614
+ const context = await getContext(relConfig, relPrisma, null)
615
+ await context.db.author.findMany({ query: fragment })
616
+
617
+ const call = relPrisma.author.findMany.mock.calls[0][0]
618
+ // Fragment-built include is used as-is; the merge helper is NOT applied to
619
+ // the fragment path, so the relation carries no access `where` here. (The
620
+ // fragment posts-selection contains only scalars, so it builds to `true`.)
621
+ expect(call.include).toEqual({ posts: true })
622
+ })
623
+
624
+ // Regression: when buildIncludeWithAccessControl returns `undefined` (a
625
+ // NON-denial outcome), the caller include must PASS THROUGH unchanged
626
+ // rather than every declared relation being silently dropped. The original
627
+ // #566 merge treated `undefined` as "all relations denied" (fail-closed
628
+ // data loss). `undefined` is returned in three non-denial cases:
629
+ // 1. inside a resolveOutput hook / virtual-field context,
630
+ // 2. at MAX_DEPTH,
631
+ // 3. when a list has no relationships.
632
+ describe('passes caller include through when no access include is computed', () => {
633
+ // Build an Author config with a virtual field whose resolveOutput issues a
634
+ // read WITH an explicit include. While that hook runs,
635
+ // _resolveOutputCounter.depth > 0, so buildIncludeWithAccessControl
636
+ // returns undefined for the inner read — exercising the
637
+ // `accessControlledInclude === undefined` passthrough path.
638
+ function configWithResolveOutputProbe(
639
+ callerInclude: Record<string, unknown>,
640
+ capture: (include: unknown) => void,
641
+ ): OpenSaasConfig {
642
+ return {
643
+ ...relConfig,
644
+ lists: {
645
+ ...relConfig.lists,
646
+ Author: {
647
+ ...relConfig.lists.Author,
648
+ fields: {
649
+ ...relConfig.lists.Author.fields,
650
+ commentSummary: virtual({
651
+ type: 'string',
652
+ hooks: {
653
+ resolveOutput: async ({ context }) => {
654
+ await context.db.comment.findMany({ include: callerInclude })
655
+ capture(relPrisma.comment.findMany.mock.calls[0][0].include)
656
+ return 'summary'
657
+ },
658
+ },
659
+ }),
660
+ },
661
+ },
662
+ },
663
+ }
664
+ }
665
+
666
+ it('findUnique inside a resolveOutput hook keeps the caller include (not dropped)', async () => {
667
+ let innerIncludeSeen: unknown
668
+ const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
669
+ innerIncludeSeen = include
670
+ })
671
+
672
+ relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
673
+ relPrisma.comment.findMany.mockResolvedValue([{ id: 'c1', body: 'hi', post: null }])
674
+
675
+ const context = await getContext(hookConfig, relPrisma, null)
676
+ await context.db.author.findUnique({ where: { id: 'a1' } })
677
+
678
+ // The caller include `{ post: true }` survives: the declared `post`
679
+ // relation is NOT dropped despite the access include being undefined here.
680
+ expect(innerIncludeSeen).toEqual({ post: true })
681
+ })
682
+
683
+ it('findMany inside a resolveOutput hook passes a NESTED caller include through whole', async () => {
684
+ let innerIncludeSeen: unknown
685
+ const hookConfig = configWithResolveOutputProbe(
686
+ { post: { include: { author: true } } },
687
+ (include) => {
688
+ innerIncludeSeen = include
689
+ },
690
+ )
691
+
692
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
693
+ relPrisma.comment.findMany.mockResolvedValue([{ id: 'c1', body: 'hi', post: null }])
694
+
695
+ const context = await getContext(hookConfig, relPrisma, null)
696
+ await context.db.author.findMany()
697
+
698
+ // The whole nested caller include passes through untouched (no access
699
+ // include exists to merge against in resolveOutput context).
700
+ expect(innerIncludeSeen).toEqual({ post: { include: { author: true } } })
701
+ })
702
+
703
+ // The MAX_DEPTH and no-relationships cases also make
704
+ // buildIncludeWithAccessControl return undefined; they flow through the
705
+ // identical `accessControlledInclude === undefined` branch verified above,
706
+ // so the resolveOutput probe covers all three non-denial cases.
707
+ })
708
+ })
709
+
229
710
  it('should delegate create to prisma with access control and hooks', async () => {
230
711
  const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
231
712
  mockPrisma.user.create.mockResolvedValue(mockUser)
@@ -778,17 +778,31 @@ describe('Field Types', () => {
778
778
  const field = json({ validation: { isRequired: true } })
779
779
  const schema = field.getZodSchema('metadata', 'create')
780
780
 
781
+ // Any present non-null JSON value is accepted, including falsy values
781
782
  expect(schema.safeParse({ key: 'value' }).success).toBe(true)
782
- // JSON field with isRequired still accepts undefined due to z.unknown() behavior
783
- expect(schema.safeParse(undefined).success).toBe(true)
783
+ expect(schema.safeParse([1, 2, 3]).success).toBe(true)
784
+ expect(schema.safeParse(0).success).toBe(true)
785
+ expect(schema.safeParse('').success).toBe(true)
786
+ expect(schema.safeParse(false).success).toBe(true)
787
+ // A required json field must reject undefined on create (issue #597)
788
+ expect(schema.safeParse(undefined).success).toBe(false)
789
+ // A required json field means non-null: reject a present null (issue #604)
790
+ expect(schema.safeParse(null).success).toBe(false)
784
791
  })
785
792
 
786
- test('allows undefined for required field in update mode', () => {
793
+ test('allows undefined but rejects null for required field in update mode', () => {
787
794
  const field = json({ validation: { isRequired: true } })
788
795
  const schema = field.getZodSchema('metadata', 'update')
789
796
 
790
797
  expect(schema.safeParse({ key: 'value' }).success).toBe(true)
798
+ // Omitted/undefined still passes on update (issue #570)
791
799
  expect(schema.safeParse(undefined).success).toBe(true)
800
+ // Present non-null falsy values pass
801
+ expect(schema.safeParse(0).success).toBe(true)
802
+ expect(schema.safeParse('').success).toBe(true)
803
+ expect(schema.safeParse(false).success).toBe(true)
804
+ // A present null is rejected — required json means non-null (issue #604)
805
+ expect(schema.safeParse(null).success).toBe(false)
792
806
  })
793
807
  })
794
808