@opensaas/stack-core 0.39.2 → 0.41.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 (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -48,6 +48,7 @@ describe('getContext', () => {
48
48
  fields: {
49
49
  name: { type: 'text' },
50
50
  email: { type: 'text', isIndexed: 'unique' },
51
+ posts: { type: 'relationship', ref: 'Post.author', many: true },
51
52
  },
52
53
  access: {
53
54
  operation: {
@@ -895,6 +896,224 @@ describe('getContext', () => {
895
896
  const created = result as { created: boolean; fieldErrors?: Record<string, string> }
896
897
  expect(created.fieldErrors?.title).toBeDefined()
897
898
  })
899
+
900
+ // Prisma 7 driver adapters (verified against @prisma/adapter-pg and PGlite,
901
+ // issue #979) leave `meta.target` undefined and put the equivalent data at
902
+ // `meta.driverAdapterError.cause` instead, as free-text-adjacent structured
903
+ // data rather than the documented shape.
904
+ describe('P2002 under Prisma 7 driver adapters (issue #979)', () => {
905
+ it('produces per-field errors for a composite unique violation', async () => {
906
+ mockPrisma.post.create.mockRejectedValue({
907
+ code: 'P2002',
908
+ meta: {
909
+ modelName: 'Post',
910
+ driverAdapterError: {
911
+ name: 'DriverAdapterError',
912
+ cause: {
913
+ originalCode: '23505',
914
+ originalMessage:
915
+ 'duplicate key value violates unique constraint "post_title_content_key"',
916
+ kind: 'UniqueConstraintViolation',
917
+ constraint: { fields: ['title', 'content'] },
918
+ },
919
+ },
920
+ },
921
+ })
922
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
923
+
924
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
925
+ const result = await context.serverAction({
926
+ listKey: 'Post',
927
+ action: 'createRelated',
928
+ data: { title: 'Dup', content: 'Dup' },
929
+ field: 'author',
930
+ parentId: 'u1',
931
+ })
932
+
933
+ expect(result).toMatchObject({ created: false })
934
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
935
+ expect(created.fieldErrors?.title).toBeDefined()
936
+ expect(created.fieldErrors?.content).toBeDefined()
937
+ })
938
+
939
+ it('produces a field error for a single-column unique violation', async () => {
940
+ mockPrisma.post.create.mockRejectedValue({
941
+ code: 'P2002',
942
+ meta: {
943
+ modelName: 'Post',
944
+ driverAdapterError: {
945
+ cause: {
946
+ originalMessage:
947
+ 'duplicate key value violates unique constraint "post_title_key"',
948
+ constraint: { fields: ['title'] },
949
+ },
950
+ },
951
+ },
952
+ })
953
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
954
+
955
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
956
+ const result = await context.serverAction({
957
+ listKey: 'Post',
958
+ action: 'createRelated',
959
+ data: { title: 'Dup' },
960
+ field: 'author',
961
+ parentId: 'u1',
962
+ })
963
+
964
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
965
+ expect(created.fieldErrors?.title).toBeDefined()
966
+ })
967
+
968
+ it('strips quotes so a camelCase column is keyed correctly (not left quoted)', async () => {
969
+ // Postgres quotes an identifier in `constraint.fields` only when it
970
+ // needed quoting — a camelCase column arrives as `"authorId"`. A naive
971
+ // fix that skips stripping would key fieldErrors by the literal string
972
+ // `"authorId"` (quotes included), missing the real field name.
973
+ mockPrisma.post.create.mockRejectedValue({
974
+ code: 'P2002',
975
+ meta: {
976
+ driverAdapterError: {
977
+ cause: {
978
+ originalMessage:
979
+ 'duplicate key value violates unique constraint "post_authorId_key"',
980
+ constraint: { fields: ['"authorId"'] },
981
+ },
982
+ },
983
+ },
984
+ })
985
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
986
+
987
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
988
+ const result = await context.serverAction({
989
+ listKey: 'Post',
990
+ action: 'createRelated',
991
+ data: { title: 'Dup' },
992
+ field: 'author',
993
+ parentId: 'u1',
994
+ })
995
+
996
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
997
+ expect(created.fieldErrors).toEqual({ authorId: 'This value is already in use' })
998
+ })
999
+
1000
+ it('humanizes a camelCase field name in the message instead of running words together', async () => {
1001
+ // Before this fix, `target` was always empty under a driver adapter, so
1002
+ // this label-formatting path never ran for a camelCase column. Recovering
1003
+ // real column names makes it reachable — verify it reads as words, not
1004
+ // "tenantid".
1005
+ const camelCaseConfig: OpenSaasConfig = {
1006
+ ...config,
1007
+ lists: {
1008
+ ...config.lists,
1009
+ Post: {
1010
+ ...config.lists.Post,
1011
+ fields: { ...config.lists.Post.fields, tenantSlug: { type: 'text' } },
1012
+ },
1013
+ },
1014
+ }
1015
+ mockPrisma.post.create.mockRejectedValue({
1016
+ code: 'P2002',
1017
+ meta: {
1018
+ driverAdapterError: {
1019
+ cause: {
1020
+ originalMessage:
1021
+ 'duplicate key value violates unique constraint "post_tenantSlug_key"',
1022
+ constraint: { fields: ['"tenantSlug"'] },
1023
+ },
1024
+ },
1025
+ },
1026
+ })
1027
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1028
+
1029
+ const context = await getContext(camelCaseConfig, mockPrisma, { userId: 'u1' })
1030
+ const result = await context.serverAction({
1031
+ listKey: 'Post',
1032
+ action: 'createRelated',
1033
+ data: { title: 'Dup' },
1034
+ field: 'author',
1035
+ parentId: 'u1',
1036
+ })
1037
+
1038
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
1039
+ expect(created.fieldErrors).toEqual({
1040
+ tenantSlug: 'This tenant slug is already in use',
1041
+ })
1042
+ })
1043
+
1044
+ it('leaves an already-populated meta.target unaffected (existing path still wins)', async () => {
1045
+ mockPrisma.post.create.mockRejectedValue({
1046
+ code: 'P2002',
1047
+ meta: {
1048
+ target: ['title'],
1049
+ // Present but must be ignored — meta.target already answers the question.
1050
+ driverAdapterError: {
1051
+ cause: {
1052
+ originalMessage: 'duplicate key value violates unique constraint "other_key"',
1053
+ constraint: { fields: ['content'] },
1054
+ },
1055
+ },
1056
+ },
1057
+ })
1058
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1059
+
1060
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
1061
+ const result = await context.serverAction({
1062
+ listKey: 'Post',
1063
+ action: 'createRelated',
1064
+ data: { title: 'Dup' },
1065
+ field: 'author',
1066
+ parentId: 'u1',
1067
+ })
1068
+
1069
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
1070
+ expect(created.fieldErrors).toEqual({ title: 'This title is already in use' })
1071
+ })
1072
+
1073
+ it('falls back to the generic message (not a throw) when nothing is recoverable', async () => {
1074
+ mockPrisma.post.create.mockRejectedValue({ code: 'P2002', meta: {} })
1075
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1076
+
1077
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
1078
+ const result = await context.serverAction({
1079
+ listKey: 'Post',
1080
+ action: 'createRelated',
1081
+ data: { title: 'Dup' },
1082
+ field: 'author',
1083
+ parentId: 'u1',
1084
+ })
1085
+
1086
+ expect(result).toEqual({
1087
+ created: false,
1088
+ error: 'A record with this value already exists',
1089
+ fieldErrors: {},
1090
+ })
1091
+ })
1092
+
1093
+ it('leaves a non-P2002 Prisma error unaffected', async () => {
1094
+ mockPrisma.post.create.mockRejectedValue({
1095
+ code: 'P2025',
1096
+ meta: { driverAdapterError: { cause: { constraint: { fields: ['title'] } } } },
1097
+ message: 'Record to update not found',
1098
+ })
1099
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1100
+
1101
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
1102
+ const result = await context.serverAction({
1103
+ listKey: 'Post',
1104
+ action: 'createRelated',
1105
+ data: { title: 'Dup' },
1106
+ field: 'author',
1107
+ parentId: 'u1',
1108
+ })
1109
+
1110
+ expect(result).toEqual({
1111
+ created: false,
1112
+ error: 'Record to update not found',
1113
+ fieldErrors: {},
1114
+ })
1115
+ })
1116
+ })
898
1117
  })
899
1118
 
900
1119
  describe('relationshipOptions', () => {
@@ -2063,6 +2282,59 @@ describe('getContext', () => {
2063
2282
  expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
2064
2283
  })
2065
2284
 
2285
+ it('caller `_count` folds the relation access where into the select (#1087)', async () => {
2286
+ relPrisma.author.findMany.mockResolvedValue([
2287
+ { id: 'a1', name: 'Jo', _count: { posts: 3 } },
2288
+ ])
2289
+
2290
+ const context = await getContext(relConfig, relPrisma, null)
2291
+ const result = await context.db.author.findMany({
2292
+ include: { _count: { select: { posts: true } } },
2293
+ })
2294
+
2295
+ const call = relPrisma.author.findMany.mock.calls[0][0]
2296
+ expect(call.include._count).toEqual({
2297
+ select: { posts: { where: { status: { equals: 'published' } } } },
2298
+ })
2299
+ expect(result[0]._count).toEqual({ posts: 3 })
2300
+ })
2301
+
2302
+ it('caller `_count` on a fully-denied relation is omitted from the select and returns 0 (#1087)', async () => {
2303
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
2304
+
2305
+ const context = await getContext(relConfig, relPrisma, null)
2306
+ const result = await context.db.author.findMany({
2307
+ include: { _count: { select: { posts: true, secrets: true } } },
2308
+ })
2309
+
2310
+ const call = relPrisma.author.findMany.mock.calls[0][0]
2311
+ // The denied `Secret` list is never asked to be counted...
2312
+ expect(call.include._count).toEqual({
2313
+ select: { posts: { where: { status: { equals: 'published' } } } },
2314
+ })
2315
+ // ...but the caller still sees an explicit `0`, never an absent key —
2316
+ // a count is session-relative, and `0` is what "no visible rows" means.
2317
+ expect(result[0]._count).toEqual({ secrets: 0 })
2318
+ })
2319
+
2320
+ it('sudo `_count` is used as-is, unscoped (behaviour preserved)', async () => {
2321
+ relPrisma.author.findMany.mockResolvedValue([
2322
+ { id: 'a1', name: 'Jo', _count: { posts: 3, secrets: 7 } },
2323
+ ])
2324
+
2325
+ const context = await getContext(relConfig, relPrisma, null).sudo()
2326
+ const result = await context.db.author.findMany({
2327
+ include: { _count: { select: { posts: true, secrets: true } } },
2328
+ })
2329
+
2330
+ expect(relPrisma.author.findMany).toHaveBeenCalledWith(
2331
+ expect.objectContaining({
2332
+ include: { _count: { select: { posts: true, secrets: true } } },
2333
+ }),
2334
+ )
2335
+ expect(result[0]._count).toEqual({ posts: 3, secrets: 7 })
2336
+ })
2337
+
2066
2338
  it('AND-combines a caller nested where with the relation access where', async () => {
2067
2339
  relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
2068
2340
 
@@ -2104,7 +2376,7 @@ describe('getContext', () => {
2104
2376
  )
2105
2377
  })
2106
2378
 
2107
- it('query fragment path is unaffected by the merge (fragment include used, unfiltered)', async () => {
2379
+ it('query fragment path carries the access filter, same as the include: path (#1088)', async () => {
2108
2380
  relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
2109
2381
 
2110
2382
  const postsFragment = defineFragment<{ id: string; title: string }>()({
@@ -2120,10 +2392,144 @@ describe('getContext', () => {
2120
2392
  await context.db.author.findMany({ query: fragment })
2121
2393
 
2122
2394
  const call = relPrisma.author.findMany.mock.calls[0][0]
2123
- // Fragment-built include is used as-is; the merge helper is NOT applied to
2124
- // the fragment path, so the relation carries no access `where` here. (The
2125
- // fragment posts-selection contains only scalars, so it builds to `true`.)
2126
- expect(call.include).toEqual({ posts: true })
2395
+ // The fragment-built include now runs through the same scoping walk as
2396
+ // an explicit caller `include`, so `posts` carries Post's access where
2397
+ // (matching the `include: { posts: true }` test above) instead of a
2398
+ // bare, unfiltered `true`.
2399
+ expect(call.include).toEqual({ posts: { where: { status: { equals: 'published' } } } })
2400
+ })
2401
+
2402
+ it('drops a relation whose query access is false when named in a query fragment', async () => {
2403
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
2404
+
2405
+ const secretsFragment = defineFragment<{ id: string; value: string }>()({
2406
+ value: true,
2407
+ } as const)
2408
+ const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
2409
+ id: true,
2410
+ name: true,
2411
+ secrets: secretsFragment,
2412
+ } as const)
2413
+
2414
+ const context = await getContext(relConfig, relPrisma, null)
2415
+ await context.db.author.findMany({ query: fragment })
2416
+
2417
+ const call = relPrisma.author.findMany.mock.calls[0][0]
2418
+ // `Secret`'s query access is `() => false` — the denied relation is
2419
+ // dropped from the include entirely, same as the `include:` path.
2420
+ expect(call.include.secrets).toBeUndefined()
2421
+ })
2422
+
2423
+ it('a fragment nesting past the depth cap raises the same depth error a caller include does', async () => {
2424
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 2
2425
+ const chainConfig: OpenSaasConfig = {
2426
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
2427
+ lists: {},
2428
+ }
2429
+ for (let i = 0; i < chainLength; i++) {
2430
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
2431
+ const fields: Record<string, any> = { name: { type: 'text' } }
2432
+ if (i < chainLength - 1) fields.next = { type: 'relationship', ref: `D${i + 1}.prev` }
2433
+ if (i > 0) fields.prev = { type: 'relationship', ref: `D${i - 1}.next` }
2434
+ chainConfig.lists[`D${i}`] = { fields, access: { operation: { query: () => true } } }
2435
+ }
2436
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2437
+ const chainPrisma: any = {}
2438
+ for (let i = 0; i < chainLength; i++) {
2439
+ chainPrisma[`d${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
2440
+ }
2441
+
2442
+ // A fragment selecting `next` recursively, `hops` levels deep.
2443
+ function nestedFragmentFields(hops: number): Record<string, unknown> {
2444
+ if (hops <= 0) return { name: true }
2445
+ return {
2446
+ name: true,
2447
+ next: defineFragment<Record<string, unknown>>()(nestedFragmentFields(hops - 1)),
2448
+ }
2449
+ }
2450
+ const deepFragment = defineFragment<Record<string, unknown>>()(
2451
+ nestedFragmentFields(READ_INCLUDE_MAX_DEPTH + 1),
2452
+ )
2453
+
2454
+ const context = await getContext(chainConfig, chainPrisma, null)
2455
+
2456
+ await expect(context.db.d0.findMany({ query: deepFragment })).rejects.toThrow(
2457
+ AccessScopeDepthExceededError,
2458
+ )
2459
+ expect(chainPrisma.d0.findMany).not.toHaveBeenCalled()
2460
+ })
2461
+
2462
+ it('sudo query fragment reads stay unscoped (behaviour preserved)', async () => {
2463
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
2464
+
2465
+ const secretsFragment = defineFragment<{ id: string; value: string }>()({
2466
+ value: true,
2467
+ } as const)
2468
+ const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
2469
+ id: true,
2470
+ name: true,
2471
+ secrets: secretsFragment,
2472
+ } as const)
2473
+
2474
+ const context = await getContext(relConfig, relPrisma, null).sudo()
2475
+ await context.db.author.findMany({ query: fragment })
2476
+
2477
+ // Under sudo, the fragment-built include is used as-is: the denied
2478
+ // `Secret` relation is fetched unfiltered, matching sudo's existing
2479
+ // include: behaviour.
2480
+ expect(relPrisma.author.findMany).toHaveBeenCalledWith(
2481
+ expect.objectContaining({ include: { secrets: true } }),
2482
+ )
2483
+ })
2484
+
2485
+ it('a fragment read on a to-one relation nulls out a row its related list denies, matching include: (#974)', async () => {
2486
+ // `Comment.post` is a to-one relation onto `Post`, whose query access
2487
+ // is a row filter (`status: published`) rather than a plain boolean —
2488
+ // Prisma can't carry that as a `where` on a to-one include, so it's
2489
+ // resolved via the post-query existence check instead.
2490
+ const postFragment = defineFragment<{ id: string; title: string }>()({
2491
+ title: true,
2492
+ } as const)
2493
+ const fragment = defineFragment<{ id: string; body: string; post: unknown }>()({
2494
+ id: true,
2495
+ body: true,
2496
+ post: postFragment,
2497
+ } as const)
2498
+
2499
+ relPrisma.comment.findMany.mockResolvedValue([
2500
+ { id: 'c1', body: 'hi', post: { id: 'p1', title: 'Draft' } },
2501
+ ])
2502
+ // The batched existence check queries the raw Post model directly;
2503
+ // an empty result means `p1` does not satisfy Post's access filter.
2504
+ relPrisma.post.findMany.mockResolvedValue([])
2505
+
2506
+ const context = await getContext(relConfig, relPrisma, null)
2507
+ const result = await context.db.comment.findMany({ query: fragment })
2508
+
2509
+ expect(result[0].post).toBeNull()
2510
+ })
2511
+
2512
+ it('a fragment read and an equivalent include: read produce the same access-scoped include', async () => {
2513
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
2514
+
2515
+ const postsFragment = defineFragment<{ id: string; title: string }>()({
2516
+ title: true,
2517
+ } as const)
2518
+ const fragment = defineFragment<{ id: string; name: string; posts: unknown }>()({
2519
+ id: true,
2520
+ name: true,
2521
+ posts: postsFragment,
2522
+ } as const)
2523
+
2524
+ const context = await getContext(relConfig, relPrisma, null)
2525
+ await context.db.author.findMany({ query: fragment })
2526
+ const fragmentInclude = relPrisma.author.findMany.mock.calls[0][0].include
2527
+
2528
+ relPrisma.author.findMany.mockClear()
2529
+ await context.db.author.findMany({ include: { posts: true } })
2530
+ const callerInclude = relPrisma.author.findMany.mock.calls[0][0].include
2531
+
2532
+ expect(fragmentInclude).toEqual(callerInclude)
2127
2533
  })
2128
2534
 
2129
2535
  // Core new guarantee introduced by #852 / ADR-0026: naming one relation
@@ -2194,7 +2600,7 @@ describe('getContext', () => {
2194
2600
  }
2195
2601
  }
2196
2602
 
2197
- it('findUnique inside a resolveOutput hook row-scopes the relation instead of dropping the where', async () => {
2603
+ it('findUnique inside a resolveOutput hook still fetches the relation, unscoped at the Prisma level (#974)', async () => {
2198
2604
  let innerIncludeSeen: unknown
2199
2605
  const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
2200
2606
  innerIncludeSeen = include
@@ -2206,9 +2612,11 @@ describe('getContext', () => {
2206
2612
  const context = await getContext(hookConfig, relPrisma, null)
2207
2613
  await context.db.author.findUnique({ where: { id: 'a1' } })
2208
2614
 
2209
- // `post` is still fetched (not dropped) but now carries Post's own
2210
- // query-access `where` it is no longer a bare, unscoped `true`.
2211
- expect(innerIncludeSeen).toEqual({ post: { where: { status: { equals: 'published' } } } })
2615
+ // `post` is still fetched (not dropped). It carries no `where` — Post
2616
+ // is a to-one relation here, and Prisma rejects a `where` on a to-one
2617
+ // include (#974); Post's own access filter is enforced afterward via
2618
+ // a batched existence check instead (see access-filter.test.ts).
2619
+ expect(innerIncludeSeen).toEqual({ post: true })
2212
2620
  })
2213
2621
 
2214
2622
  it('findMany inside a resolveOutput hook scopes the relation but does not auto-expand its nested include', async () => {
@@ -2227,10 +2635,10 @@ describe('getContext', () => {
2227
2635
  await context.db.author.findMany()
2228
2636
 
2229
2637
  // The caller's own nested selection (`post.include.author`) is honoured
2230
- // as-is (access control does not auto-descend further here), while
2231
- // `post` itself picks up its access `where`.
2638
+ // as-is (access control does not auto-descend further here). `post`
2639
+ // itself carries no `where` see the findUnique test above (#974).
2232
2640
  expect(innerIncludeSeen).toEqual({
2233
- post: { where: { status: { equals: 'published' } }, include: { author: true } },
2641
+ post: { include: { author: true } },
2234
2642
  })
2235
2643
  })
2236
2644
  })
@@ -2289,7 +2697,7 @@ describe('getContext', () => {
2289
2697
  expect(chainPrisma.c0.findMany).not.toHaveBeenCalled()
2290
2698
  })
2291
2699
 
2292
- it('still returns correctly row-scoped data for the same include one hop shallower', async () => {
2700
+ it('still applies row-scoping at the deepest hop for the same include one hop shallower', async () => {
2293
2701
  const chainLength = READ_INCLUDE_MAX_DEPTH + 1
2294
2702
  const chainConfig: OpenSaasConfig = {
2295
2703
  db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
@@ -2300,7 +2708,20 @@ describe('getContext', () => {
2300
2708
  for (let i = 0; i < chainLength; i++) {
2301
2709
  chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
2302
2710
  }
2303
- chainPrisma.c0.findMany.mockResolvedValue([])
2711
+
2712
+ // Every hop here is to-one (no `many: true`), so none of them can
2713
+ // carry a `where` on the Prisma include (#974) — the chain comes
2714
+ // back unscoped at the Prisma level, one nested object per hop, and
2715
+ // row-scoping is enforced afterward via a batched existence check
2716
+ // per hop instead.
2717
+ let deepestItem: Record<string, unknown> = { id: `id${chainLength - 1}`, name: 'leaf' }
2718
+ for (let i = chainLength - 2; i >= 0; i--) {
2719
+ deepestItem = { id: `id${i}`, name: 'x', next: deepestItem }
2720
+ }
2721
+ chainPrisma.c0.findMany.mockResolvedValue([deepestItem])
2722
+ for (let i = 1; i < chainLength; i++) {
2723
+ chainPrisma[`c${i}`].findMany.mockResolvedValue([{ id: `id${i}` }])
2724
+ }
2304
2725
 
2305
2726
  const context = await getContext(chainConfig, chainPrisma, null)
2306
2727
 
@@ -2308,13 +2729,27 @@ describe('getContext', () => {
2308
2729
  include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH - 1) },
2309
2730
  })
2310
2731
 
2311
- // Walk the built include down to the last list — it must carry that
2312
- // list's own access `where`, proving row scoping, not just "no throw".
2732
+ // Walk the built include down to the last list — no hop carries a
2733
+ // `where` (they are all to-one).
2313
2734
  let entry = chainPrisma.c0.findMany.mock.calls[0][0].include.next
2314
2735
  for (let i = 1; i < READ_INCLUDE_MAX_DEPTH; i++) {
2736
+ expect(entry.where).toBeUndefined()
2315
2737
  entry = entry.include.next
2316
2738
  }
2317
- expect(entry.where).toEqual({ ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } })
2739
+ expect(entry.where).toBeUndefined()
2740
+
2741
+ // The deepest hop's own existence check still carries that list's
2742
+ // access filter — row scoping reaches all the way down, just not
2743
+ // through the Prisma `include` itself.
2744
+ expect(chainPrisma[`c${READ_INCLUDE_MAX_DEPTH}`].findMany).toHaveBeenCalledWith({
2745
+ where: {
2746
+ AND: [
2747
+ { ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } },
2748
+ { id: { in: [`id${READ_INCLUDE_MAX_DEPTH}`] } },
2749
+ ],
2750
+ },
2751
+ select: { id: true },
2752
+ })
2318
2753
  })
2319
2754
  })
2320
2755